elmarcito commited on
Commit
a287163
·
verified ·
1 Parent(s): 30767d0

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +27 -0
  2. README.md +50 -10
  3. app.py +1178 -0
  4. requirements.txt +3 -0
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Dipendenze sistema per curl_cffi (compila C bindings)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ gcc \
6
+ libffi-dev \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # HF Spaces richiede un utente non-root
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH
14
+
15
+ WORKDIR $HOME/app
16
+
17
+ # Copia requirements e installa
18
+ COPY --chown=user requirements.txt .
19
+ RUN pip install --no-cache-dir --user -r requirements.txt
20
+
21
+ # Copia app
22
+ COPY --chown=user . .
23
+
24
+ # HF Spaces espone SEMPRE porta 7860
25
+ EXPOSE 7860
26
+
27
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,50 @@
1
- ---
2
- title: IMGGENAI
3
- emoji: 📉
4
- colorFrom: pink
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ \---
2
+
3
+ title: Krea-2 API
4
+
5
+ emoji:
6
+
7
+ colorFrom: purple
8
+
9
+ colorTo: pink
10
+
11
+ sdk: docker
12
+
13
+ app\_port: 7860
14
+
15
+ pinned: false
16
+
17
+ license: mit
18
+
19
+ \---
20
+
21
+
22
+
23
+ \# Krea-2 API
24
+
25
+
26
+
27
+ REST API for AI image generation via Krea-2 model.
28
+
29
+ Proxy-load-balanced, agent-ready, no authentication required.
30
+
31
+
32
+
33
+ \## Endpoints
34
+
35
+
36
+
37
+ \- `GET /generate?prompt=...` — generate image
38
+
39
+ \- `GET /generate?prompt=...\&n=5` — 5 variants in parallel
40
+
41
+ \- `GET /docs` — Swagger UI
42
+
43
+ \- `GET /tool-schema` — Tool schema for AI agents
44
+
45
+
46
+
47
+ \## Usage
48
+
49
+ GET https://elmarcito-krea2-api.hf.space/generate?prompt=a+cyberpunk+cat
50
+
app.py ADDED
@@ -0,0 +1,1178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Krea-2 as-a-Service — HTTP API wrapper
4
+ Espone il client krea/Krea-2 come REST API GET, ideale per agent AI / tool calling.
5
+
6
+ Uso:
7
+ python api.py # avvia server su :7860
8
+ python api.py --port 8080 --host 0.0.0.0
9
+
10
+ Endpoints principali (tutti GET):
11
+ GET / → info + docs
12
+ GET /health → healthcheck
13
+ GET /generate?prompt=... → genera 1 immagine (sync)
14
+ GET /generate?prompt=...&n=5 → genera N in parallelo
15
+ GET /batch?prompts=a|b|c → batch da lista pipe-separated
16
+ GET /jobs/{job_id} → stato job async
17
+ GET /jobs/{job_id}/result → risultato quando pronto
18
+ GET /image/{filename} → scarica file generato
19
+ GET /stats → statistiche DB proxy
20
+ GET /openapi.json → schema OpenAPI per agent
21
+
22
+ pip install curl_cffi fastapi uvicorn
23
+ """
24
+
25
+ import argparse
26
+ import asyncio
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import time
32
+ import uuid
33
+ import random
34
+ import string
35
+ import sqlite3
36
+ import threading
37
+ from concurrent.futures import ThreadPoolExecutor, as_completed
38
+ from dataclasses import dataclass, field, asdict
39
+ from pathlib import Path
40
+ from typing import Optional, Literal
41
+ from datetime import datetime
42
+
43
+ from curl_cffi import requests as cffi_requests
44
+ from fastapi import FastAPI, HTTPException, Query, BackgroundTasks, Request
45
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
46
+ from fastapi.middleware.cors import CORSMiddleware
47
+ import uvicorn
48
+
49
+ # ═══════════════════════════════════════════════════════════════
50
+ # CONFIG
51
+ # ═══════════════════════════════════════════════════════════════
52
+
53
+ HF_BASE = "https://huggingface.co"
54
+ SPACE_BASE = "https://krea-krea-2.hf.space"
55
+ SPACE_ID = "krea/Krea-2"
56
+ JWT_URL = f"{HF_BASE}/api/spaces/{SPACE_ID}/jwt"
57
+ JOIN_URL = f"{SPACE_BASE}/gradio_api/queue/join"
58
+ DATA_URL = f"{SPACE_BASE}/gradio_api/queue/data"
59
+
60
+ FN_RESOLUTION = 3
61
+ FN_GENERATE = 4
62
+
63
+ DEFAULT_WORKERS = 30
64
+ WAVE_SIZE = 25
65
+ WAVE_DELAY_MS = 150
66
+ TOKEN_TIMEOUT = 8
67
+ STREAM_TIMEOUT = 90
68
+
69
+ DB_PATH = Path("proxy_pool.db")
70
+ IMAGES_DIR = Path("generated_images")
71
+ IMAGES_DIR.mkdir(exist_ok=True)
72
+
73
+ BAN_TTL_SECONDS = 3600 * 6
74
+ GOOD_TTL_DAYS = 7
75
+ MIN_GOOD_RATIO = 0.4
76
+ MAX_PARALLEL_JOBS = 10
77
+
78
+ # Job registry TTL (dopo quanto pulire i job vecchi)
79
+ JOB_TTL_SECONDS = 3600 # 1h
80
+
81
+ PROXYSCRAPE_URLS = [
82
+ "https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=3000&country=all&ssl=all&anonymity=all",
83
+ "https://api.proxyscrape.com/v2/?request=displayproxies&protocol=socks4&timeout=3000&country=all&ssl=all&anonymity=all",
84
+ "https://api.proxyscrape.com/v2/?request=displayproxies&protocol=socks5&timeout=3000&country=all&ssl=all&anonymity=all",
85
+ ]
86
+
87
+ IMPERSONATE_POOL = [
88
+ ("chrome124", "Windows NT 10.0; Win64; x64", "Chrome/124.0.0.0"),
89
+ ("chrome123", "Macintosh; Intel Mac OS X 10_15_7", "Chrome/123.0.0.0"),
90
+ ("chrome120", "X11; Linux x86_64", "Chrome/120.0.0.0"),
91
+ ("chrome116", "Windows NT 10.0; Win64; x64", "Chrome/116.0.0.0"),
92
+ ]
93
+
94
+ LANGS = [
95
+ "en-US,en;q=0.9", "it-IT,it;q=0.9,en;q=0.8",
96
+ "en-GB,en;q=0.9", "fr-FR,fr;q=0.9,en;q=0.8",
97
+ "de-DE,de;q=0.9,en;q=0.8", "es-ES,es;q=0.9,en;q=0.8",
98
+ ]
99
+
100
+ RESOLUTIONS = {
101
+ "square": (1024, 1024),
102
+ "portrait": (1024, 1536),
103
+ "landscape": (1536, 1024),
104
+ "square2k": (2048, 2048),
105
+ }
106
+
107
+
108
+ # ═══════════════════════════════════════════════════════════════
109
+ # PROXY DB (SQLite)
110
+ # ═══════════════════════════════════════════════════════════════
111
+
112
+ class ProxyDB:
113
+ def __init__(self, path=DB_PATH):
114
+ self.path = path
115
+ self._lock = threading.Lock()
116
+ self._init_db()
117
+
118
+ def _conn(self):
119
+ conn = sqlite3.connect(self.path, timeout=10, check_same_thread=False)
120
+ conn.execute("PRAGMA journal_mode=WAL")
121
+ conn.execute("PRAGMA synchronous=NORMAL")
122
+ return conn
123
+
124
+ def _init_db(self):
125
+ with self._conn() as c:
126
+ c.execute("""
127
+ CREATE TABLE IF NOT EXISTS proxies (
128
+ url TEXT PRIMARY KEY, protocol TEXT NOT NULL,
129
+ successes INTEGER DEFAULT 0, failures INTEGER DEFAULT 0,
130
+ avg_latency_ms REAL DEFAULT 0.0,
131
+ last_success REAL DEFAULT 0, last_failure REAL DEFAULT 0,
132
+ banned_until REAL DEFAULT 0,
133
+ first_seen REAL DEFAULT 0, score REAL DEFAULT 0
134
+ )""")
135
+ c.execute("CREATE INDEX IF NOT EXISTS idx_score ON proxies(score DESC)")
136
+ c.commit()
137
+
138
+ def upsert_many(self, proxies):
139
+ with self._lock, self._conn() as c:
140
+ now = time.time()
141
+ for p in proxies:
142
+ c.execute("INSERT OR IGNORE INTO proxies (url, protocol, first_seen) VALUES (?, ?, ?)",
143
+ (p["url"], p["protocol"], now))
144
+ c.commit()
145
+
146
+ def get_ranked(self, limit, min_good_ratio=0.4, exclude: set = None):
147
+ exclude = exclude or set()
148
+ with self._lock, self._conn() as c:
149
+ now = time.time()
150
+ n_good = int(limit * min_good_ratio)
151
+ n_new = limit - n_good
152
+ good = c.execute("""
153
+ SELECT url, protocol, successes, failures, avg_latency_ms, score FROM proxies
154
+ WHERE banned_until < ? AND successes > 0
155
+ ORDER BY score DESC, avg_latency_ms ASC LIMIT ?
156
+ """, (now, n_good * 2)).fetchall()
157
+ new_ones = c.execute("""
158
+ SELECT url, protocol, successes, failures, avg_latency_ms, score FROM proxies
159
+ WHERE banned_until < ? AND successes = 0 AND failures < 3
160
+ ORDER BY RANDOM() LIMIT ?
161
+ """, (now, n_new * 2)).fetchall()
162
+ rows = [r for r in list(good) + list(new_ones) if r[0] not in exclude][:limit]
163
+ return [{"url": r[0], "protocol": r[1], "successes": r[2],
164
+ "failures": r[3], "avg_latency_ms": r[4], "score": r[5]}
165
+ for r in rows]
166
+
167
+ def mark_success(self, url, latency_ms):
168
+ with self._lock, self._conn() as c:
169
+ row = c.execute("SELECT successes, avg_latency_ms FROM proxies WHERE url=?", (url,)).fetchone()
170
+ if row:
171
+ s, old_lat = row
172
+ new_s = s + 1
173
+ new_lat = old_lat * 0.7 + latency_ms * 0.3 if old_lat > 0 else latency_ms
174
+ score = new_s * 100 - (new_lat / 100)
175
+ c.execute("UPDATE proxies SET successes=?, avg_latency_ms=?, last_success=?, banned_until=0, score=? WHERE url=?",
176
+ (new_s, new_lat, time.time(), score, url))
177
+ c.commit()
178
+
179
+ def mark_failure(self, url, ban=False):
180
+ with self._lock, self._conn() as c:
181
+ row = c.execute("SELECT successes, failures FROM proxies WHERE url=?", (url,)).fetchone()
182
+ if not row: return
183
+ s, f = row
184
+ new_f = f + 1
185
+ banned_until = 0
186
+ if ban or (new_f >= 3 and s == 0):
187
+ banned_until = time.time() + BAN_TTL_SECONDS
188
+ score = s * 100 - new_f * 10
189
+ c.execute("UPDATE proxies SET failures=?, last_failure=?, banned_until=?, score=? WHERE url=?",
190
+ (new_f, time.time(), banned_until, score, url))
191
+ c.commit()
192
+
193
+ def stats(self):
194
+ with self._lock, self._conn() as c:
195
+ r = c.execute("""SELECT COUNT(*), SUM(CASE WHEN successes>0 THEN 1 ELSE 0 END),
196
+ SUM(CASE WHEN banned_until>? THEN 1 ELSE 0 END),
197
+ SUM(CASE WHEN successes=0 AND failures=0 THEN 1 ELSE 0 END)
198
+ FROM proxies""", (time.time(),)).fetchone()
199
+ return {"total": r[0] or 0, "good": r[1] or 0,
200
+ "banned": r[2] or 0, "untested": r[3] or 0}
201
+
202
+ DB = ProxyDB()
203
+
204
+
205
+ def _fetch_one(url):
206
+ try:
207
+ r = cffi_requests.get(url, timeout=8, impersonate="chrome120")
208
+ if r.status_code == 200:
209
+ protocol = "http"
210
+ if "socks4" in url: protocol = "socks4"
211
+ elif "socks5" in url: protocol = "socks5"
212
+ out = []
213
+ for line in r.text.strip().split("\n"):
214
+ line = line.strip()
215
+ if ":" not in line: continue
216
+ ip_port = line.split()[0]
217
+ proxy_url = (f"{protocol}://{ip_port}" if protocol != "http"
218
+ else f"http://{ip_port}")
219
+ out.append({"url": proxy_url, "protocol": protocol})
220
+ return out
221
+ except Exception: pass
222
+ return []
223
+
224
+ def refresh_proxy_pool():
225
+ all_proxies = []
226
+ with ThreadPoolExecutor(max_workers=3) as pool:
227
+ for chunk in pool.map(_fetch_one, PROXYSCRAPE_URLS):
228
+ all_proxies.extend(chunk)
229
+ DB.upsert_many(all_proxies)
230
+ return len(all_proxies)
231
+
232
+
233
+ # ═══════════════════════════════════════════════════════════════
234
+ # UTILS
235
+ # ═════════════════════════════���═════════════════════════════════
236
+
237
+ def _rand_hash(n=11):
238
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
239
+
240
+ def _new_identity():
241
+ imp, os_str, ver = random.choice(IMPERSONATE_POOL)
242
+ ua = f"Mozilla/5.0 ({os_str}) AppleWebKit/537.36 (KHTML, like Gecko) {ver} Safari/537.36"
243
+ return {"impersonate": imp, "ua": ua,
244
+ "accept_lang": random.choice(LANGS),
245
+ "zerogpu_uuid": str(uuid.uuid4())}
246
+
247
+ def _resolution_label(w, h):
248
+ if w == h and w >= 2048: return "Square · 2K"
249
+ if w == h: return "Square · 1024"
250
+ if w > h: return "Landscape · 1024"
251
+ return "Portrait · 1024"
252
+
253
+
254
+ # ═══════════════════════════════════════════════════════════════
255
+ # JOB STATE (identico al client, senza UI)
256
+ # ═══════════════════════════════════════════════════════════════
257
+
258
+ @dataclass
259
+ class WorkerInfo:
260
+ id: int
261
+ proxy: str = ""
262
+ status: str = "idle"
263
+ step: int = 0
264
+ total_steps: int = 8
265
+ started_at: float = 0.0
266
+
267
+ @dataclass
268
+ class GenJob:
269
+ job_id: str
270
+ prompt: str
271
+ negative_prompt: str = ""
272
+ model: str = "Turbo"
273
+ steps: int = 8
274
+ guidance: float = 0.0
275
+ width: int = 1024
276
+ height: int = 1024
277
+ seed: int = 0
278
+ randomize: bool = True
279
+
280
+ status: str = "pending" # pending|running|success|failed
281
+ created_at: float = 0.0
282
+ started_at: float = 0.0
283
+ completed_at: float = 0.0
284
+
285
+ # risultati
286
+ filename: str = "" # nome file generato
287
+ file_path: str = "" # path assoluto
288
+ file_size: int = 0
289
+ hf_url: str = "" # URL originale su HF
290
+ local_url: str = "" # URL da chiamare per scaricare
291
+ result_seed: int = 0
292
+ error: str = ""
293
+
294
+ # dettagli race
295
+ winner_proxy: str = ""
296
+ winner_latency_ms: float = 0.0
297
+ workers_launched: int = 0
298
+ workers_dead: int = 0
299
+ tokens_ok: int = 0
300
+
301
+ # runtime (non serializzati in API)
302
+ workers: dict = field(default_factory=dict)
303
+ winner_event: threading.Event = field(default_factory=threading.Event)
304
+ lock: threading.Lock = field(default_factory=threading.Lock)
305
+
306
+ def to_public_dict(self) -> dict:
307
+ d = {
308
+ "job_id": self.job_id,
309
+ "status": self.status,
310
+ "prompt": self.prompt,
311
+ "negative_prompt": self.negative_prompt,
312
+ "params": {
313
+ "model": self.model, "steps": self.steps,
314
+ "guidance": self.guidance,
315
+ "width": self.width, "height": self.height,
316
+ "seed": self.seed, "randomize": self.randomize,
317
+ },
318
+ "timing": {
319
+ "created_at": self.created_at,
320
+ "started_at": self.started_at or None,
321
+ "completed_at": self.completed_at or None,
322
+ "duration_s": (self.completed_at - self.started_at) if self.completed_at else None,
323
+ "queue_wait_s": (self.started_at - self.created_at) if self.started_at else None,
324
+ },
325
+ "workers_stats": {
326
+ "launched": self.workers_launched,
327
+ "dead": self.workers_dead,
328
+ "tokens_ok": self.tokens_ok,
329
+ },
330
+ }
331
+ if self.status == "success":
332
+ d["result"] = {
333
+ "filename": self.filename,
334
+ "file_size_bytes": self.file_size,
335
+ "seed": self.result_seed,
336
+ "hf_url": self.hf_url,
337
+ "local_url": self.local_url,
338
+ "winner_proxy": self.winner_proxy,
339
+ "winner_latency_ms": self.winner_latency_ms,
340
+ }
341
+ elif self.status == "failed":
342
+ d["error"] = self.error
343
+ return d
344
+
345
+
346
+ # Registry globale dei job
347
+ JOBS: dict[str, GenJob] = {}
348
+ JOBS_LOCK = threading.Lock()
349
+ USED_PROXIES: set = set()
350
+ USED_PROXIES_LOCK = threading.Lock()
351
+
352
+
353
+ def cleanup_old_jobs():
354
+ """Rimuove job più vecchi di JOB_TTL_SECONDS."""
355
+ now = time.time()
356
+ with JOBS_LOCK:
357
+ to_del = [jid for jid, j in JOBS.items()
358
+ if (j.completed_at or j.created_at) < now - JOB_TTL_SECONDS]
359
+ for jid in to_del:
360
+ del JOBS[jid]
361
+ return len(to_del)
362
+
363
+
364
+ # ═══════════════════════════════════════════════════════════════
365
+ # WORKER (invariato dal client v9)
366
+ # ═════════════════════════════════════════════════════════════���═
367
+
368
+ def worker_thread(job: GenJob, worker_id: int, proxy: dict):
369
+ st = job.workers[worker_id]
370
+ st.proxy = proxy["url"]
371
+ st.total_steps = job.steps
372
+
373
+ if job.winner_event.is_set():
374
+ st.status = "killed"; return None
375
+
376
+ t_start = time.time()
377
+ ident = _new_identity()
378
+ proxies = {"http": proxy["url"], "https": proxy["url"]}
379
+ st.status = "connecting"
380
+
381
+ try:
382
+ s = cffi_requests.Session(impersonate=ident["impersonate"], proxies=proxies)
383
+ s.headers.update({"user-agent": ident["ua"], "accept-language": ident["accept_lang"]})
384
+ except Exception:
385
+ st.status = "dead"
386
+ with job.lock: job.workers_dead += 1
387
+ DB.mark_failure(proxy["url"], ban=True)
388
+ return None
389
+
390
+ try:
391
+ # TOKEN
392
+ try:
393
+ r = s.get(JWT_URL, headers={
394
+ "accept": "*/*", "referer": f"{HF_BASE}/spaces/{SPACE_ID}",
395
+ "origin": HF_BASE}, timeout=TOKEN_TIMEOUT)
396
+ if r.status_code != 200: raise Exception()
397
+ token = (r.json().get("token") or r.json().get("jwt"))
398
+ if not token: raise Exception()
399
+ except Exception:
400
+ st.status = "dead"
401
+ with job.lock: job.workers_dead += 1
402
+ DB.mark_failure(proxy["url"])
403
+ return None
404
+
405
+ if job.winner_event.is_set(): st.status = "killed"; return None
406
+ st.status = "token"
407
+ with job.lock: job.tokens_ok += 1
408
+
409
+ auth = {"accept": "*/*", "content-type": "application/json",
410
+ "origin": SPACE_BASE, "referer": f"{SPACE_BASE}/?__theme=system",
411
+ "x-gradio-server": f"{SPACE_BASE}/", "x-gradio-user": "app",
412
+ "x-zerogpu-token": token, "x-zerogpu-uuid": ident["zerogpu_uuid"]}
413
+
414
+ # RESOLUTION
415
+ sh1 = _rand_hash()
416
+ try:
417
+ s.post(f"{JOIN_URL}?__theme=system", json={
418
+ "data": [_resolution_label(job.width, job.height)],
419
+ "fn_index": FN_RESOLUTION, "trigger_id": 10, "session_hash": sh1,
420
+ }, headers=auth, timeout=8)
421
+ except Exception: pass
422
+
423
+ if job.winner_event.is_set(): st.status = "killed"; return None
424
+
425
+ # GENERATE
426
+ sh2 = _rand_hash()
427
+ try:
428
+ r = s.post(f"{JOIN_URL}?__theme=system", json={
429
+ "data": [job.prompt, job.negative_prompt or None, job.model,
430
+ job.steps, job.guidance, job.width, job.height,
431
+ job.seed, job.randomize],
432
+ "fn_index": FN_GENERATE, "trigger_id": 7, "session_hash": sh2,
433
+ }, headers=auth, timeout=10)
434
+ if r.status_code != 200:
435
+ st.status = "dead"
436
+ with job.lock: job.workers_dead += 1
437
+ DB.mark_failure(proxy["url"])
438
+ return None
439
+ except Exception:
440
+ st.status = "dead"
441
+ with job.lock: job.workers_dead += 1
442
+ DB.mark_failure(proxy["url"])
443
+ return None
444
+
445
+ st.status = "queued"; st.started_at = time.time()
446
+
447
+ if job.winner_event.is_set(): st.status = "killed"; return None
448
+
449
+ # STREAM
450
+ try:
451
+ stream_r = s.get(f"{DATA_URL}?session_hash={sh2}", headers={
452
+ "accept": "text/event-stream",
453
+ "referer": f"{SPACE_BASE}/?__theme=system",
454
+ "x-gradio-server": f"{SPACE_BASE}/"}, stream=True, timeout=STREAM_TIMEOUT)
455
+ if stream_r.status_code != 200:
456
+ st.status = "dead"; DB.mark_failure(proxy["url"]); return None
457
+ except Exception:
458
+ st.status = "dead"; DB.mark_failure(proxy["url"]); return None
459
+
460
+ st.status = "streaming"
461
+
462
+ for raw in stream_r.iter_lines():
463
+ if job.winner_event.is_set(): st.status = "killed"; return None
464
+ if not raw: continue
465
+ if isinstance(raw, bytes): raw = raw.decode("utf-8", errors="ignore")
466
+ if not raw.startswith("data:"): continue
467
+ body = raw[5:].strip()
468
+ if not body: continue
469
+ try: evt = json.loads(body)
470
+ except: continue
471
+
472
+ msg = evt.get("msg")
473
+ if msg == "progress":
474
+ pd = evt.get("progress_data") or []
475
+ if pd and pd[0].get("index") is not None:
476
+ st.step = pd[0]["index"] + 1
477
+ st.total_steps = pd[0]["length"]
478
+
479
+ elif msg == "process_completed":
480
+ out = evt.get("output", {})
481
+ if out.get("error") or not out.get("data"):
482
+ st.status = "dead"
483
+ with job.lock: job.workers_dead += 1
484
+ DB.mark_failure(proxy["url"])
485
+ return None
486
+
487
+ latency = (time.time() - t_start) * 1000
488
+ DB.mark_success(proxy["url"], latency)
489
+ with job.lock:
490
+ if not job.winner_event.is_set():
491
+ job.winner_event.set()
492
+ job.winner_proxy = proxy["url"]
493
+ job.winner_latency_ms = latency
494
+ st.status = "done"
495
+ return out
496
+ return None
497
+
498
+ elif msg in ("unexpected_error", "close_stream"):
499
+ st.status = "dead"; DB.mark_failure(proxy["url"])
500
+ return None
501
+
502
+ st.status = "dead"
503
+ DB.mark_failure(proxy["url"])
504
+ return None
505
+ except Exception:
506
+ st.status = "dead"
507
+ with job.lock: job.workers_dead += 1
508
+ DB.mark_failure(proxy["url"])
509
+ return None
510
+ finally:
511
+ try: s.close()
512
+ except: pass
513
+
514
+
515
+ def execute_job(job: GenJob, n_workers: int = DEFAULT_WORKERS,
516
+ base_url: str = ""):
517
+ """Esegue un job completo. Chiamata sync bloccante."""
518
+ job.status = "running"
519
+ job.started_at = time.time()
520
+
521
+ # Prendi proxy escludendo quelli usati da altri job in corso
522
+ with USED_PROXIES_LOCK:
523
+ proxies = DB.get_ranked(limit=n_workers,
524
+ min_good_ratio=MIN_GOOD_RATIO,
525
+ exclude=USED_PROXIES)
526
+ for p in proxies:
527
+ USED_PROXIES.add(p["url"])
528
+
529
+ if not proxies:
530
+ job.status = "failed"
531
+ job.error = "No proxies available in pool. Try /admin/refresh"
532
+ job.completed_at = time.time()
533
+ return
534
+
535
+ n_workers = min(n_workers, len(proxies))
536
+ job.workers_launched = n_workers
537
+ for i in range(n_workers):
538
+ job.workers[i] = WorkerInfo(id=i, total_steps=job.steps)
539
+
540
+ try:
541
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
542
+ futures = []
543
+ for wave_start in range(0, n_workers, WAVE_SIZE):
544
+ if job.winner_event.is_set(): break
545
+ wave_end = min(wave_start + WAVE_SIZE, n_workers)
546
+ for i in range(wave_start, wave_end):
547
+ futures.append(pool.submit(worker_thread, job, i, proxies[i]))
548
+ time.sleep(WAVE_DELAY_MS / 1000)
549
+
550
+ for f in as_completed(futures):
551
+ result = f.result()
552
+ if result and result.get("data"):
553
+ data = result["data"]
554
+ img = data[0]
555
+ job.result_seed = data[1] if len(data) > 1 else 0
556
+ url = img.get("url") if isinstance(img, dict) else img
557
+ if not url.startswith("http"):
558
+ url = f"{SPACE_BASE}/gradio_api/file={url}"
559
+ job.hf_url = url
560
+
561
+ # Download nel filesystem locale
562
+ try:
563
+ filename = f"{job.job_id}.png"
564
+ file_path = IMAGES_DIR / filename
565
+ ident = _new_identity()
566
+ with cffi_requests.Session(impersonate=ident["impersonate"]) as s:
567
+ s.headers["user-agent"] = ident["ua"]
568
+ r = s.get(url, timeout=60)
569
+ r.raise_for_status()
570
+ file_path.write_bytes(r.content)
571
+ job.filename = filename
572
+ job.file_path = str(file_path.absolute())
573
+ job.file_size = len(r.content)
574
+ job.local_url = f"{base_url}/image/{filename}"
575
+ job.status = "success"
576
+ except Exception as e:
577
+ job.status = "failed"
578
+ job.error = f"download failed: {e}"
579
+ break
580
+ finally:
581
+ with USED_PROXIES_LOCK:
582
+ for p in proxies:
583
+ USED_PROXIES.discard(p["url"])
584
+ if job.status == "running":
585
+ job.status = "failed"
586
+ job.error = "No worker succeeded (all proxies failed or quota exceeded)"
587
+ job.completed_at = time.time()
588
+
589
+
590
+ def build_job(prompt: str, **kwargs) -> GenJob:
591
+ """Factory di GenJob con validazioni."""
592
+ # Risoluzione: preset o custom
593
+ width = kwargs.get("width") or 1024
594
+ height = kwargs.get("height") or 1024
595
+ if kwargs.get("resolution"):
596
+ preset = kwargs["resolution"]
597
+ if preset not in RESOLUTIONS:
598
+ raise ValueError(f"resolution must be one of {list(RESOLUTIONS.keys())}")
599
+ width, height = RESOLUTIONS[preset]
600
+
601
+ # Validazione parametri (da schema /gradio_api/info)
602
+ model = kwargs.get("model", "Turbo")
603
+ if model not in ("Turbo", "Raw"):
604
+ raise ValueError("model must be 'Turbo' or 'Raw'")
605
+
606
+ steps = int(kwargs.get("steps", 8))
607
+ if not 1 <= steps <= 50:
608
+ raise ValueError("steps must be between 1 and 50")
609
+
610
+ guidance = float(kwargs.get("guidance", 0.0))
611
+ if not 0.0 <= guidance <= 10.0:
612
+ raise ValueError("guidance must be between 0.0 and 10.0")
613
+
614
+ if not 512 <= width <= 2048:
615
+ raise ValueError("width must be between 512 and 2048")
616
+ if not 512 <= height <= 2048:
617
+ raise ValueError("height must be between 512 and 2048")
618
+
619
+ seed = kwargs.get("seed")
620
+ randomize = seed is None or seed == 0
621
+ if not randomize:
622
+ seed = int(seed)
623
+ if not 0 <= seed <= 2147483647:
624
+ raise ValueError("seed must be between 0 and 2147483647")
625
+ else:
626
+ seed = random.randint(0, 2**31 - 1)
627
+
628
+ job_id = f"job_{int(time.time()*1000)}_{_rand_hash(6)}"
629
+ job = GenJob(
630
+ job_id=job_id,
631
+ prompt=prompt,
632
+ negative_prompt=kwargs.get("negative_prompt", ""),
633
+ model=model, steps=steps, guidance=guidance,
634
+ width=width, height=height,
635
+ seed=seed, randomize=randomize,
636
+ created_at=time.time(),
637
+ )
638
+ with JOBS_LOCK:
639
+ JOBS[job_id] = job
640
+ return job
641
+
642
+
643
+ # ═══════════════════════════════════════════════════════════════
644
+ # FASTAPI APP
645
+ # ═══════════════════════════════════════════════════════════════
646
+
647
+ app = FastAPI(
648
+ title="Krea-2 API",
649
+ description=(
650
+ "REST API for AI image generation via krea/Krea-2 HuggingFace Space.\n"
651
+ "Optimized for agentic AI tool-calling: all endpoints are GET, "
652
+ "structured JSON responses, OpenAPI schema available at /openapi.json"
653
+ ),
654
+ version="1.0.0",
655
+ docs_url="/docs",
656
+ redoc_url="/redoc",
657
+ openapi_url="/openapi.json",
658
+ )
659
+
660
+ app.add_middleware(
661
+ CORSMiddleware,
662
+ allow_origins=["*"],
663
+ allow_methods=["*"],
664
+ allow_headers=["*"],
665
+ )
666
+
667
+
668
+ def get_base_url(request: Request) -> str:
669
+ """Costruisce l'URL base per link assoluti."""
670
+ scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
671
+ host = request.headers.get("host", request.url.hostname)
672
+ return f"{scheme}://{host}"
673
+
674
+
675
+ # ─── HOME / INFO ──────────────────────────────────────────────
676
+
677
+ @app.get("/", response_class=HTMLResponse)
678
+ def home(request: Request):
679
+ """Landing page con documentazione HTML."""
680
+ base = get_base_url(request)
681
+ stats = DB.stats()
682
+ n_jobs = len(JOBS)
683
+ n_running = sum(1 for j in JOBS.values() if j.status == "running")
684
+
685
+ return f"""<!DOCTYPE html>
686
+ <html><head><title>Krea-2 API</title>
687
+ <style>
688
+ body{{font-family:system-ui,-apple-system,sans-serif;max-width:900px;
689
+ margin:2rem auto;padding:0 1rem;background:#0d0d1a;color:#e0e0f0;line-height:1.6}}
690
+ h1{{background:linear-gradient(90deg,#00ffdc,#ff50dc);-webkit-background-clip:text;
691
+ -webkit-text-fill-color:transparent;font-size:2.5rem;margin-bottom:0}}
692
+ .subtitle{{color:#a0a0c0;margin-top:0}}
693
+ code{{background:#1a1a2e;padding:.2rem .4rem;border-radius:3px;color:#00ffdc}}
694
+ pre{{background:#1a1a2e;padding:1rem;border-radius:6px;overflow-x:auto;
695
+ border-left:3px solid #ff50dc}}
696
+ .endpoint{{background:#151525;padding:.8rem 1rem;margin:.5rem 0;
697
+ border-radius:6px;border-left:3px solid #00ffdc}}
698
+ .method{{color:#00ffdc;font-weight:bold}}
699
+ a{{color:#ff50dc}}
700
+ .stats{{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin:1rem 0}}
701
+ .stat{{background:#151525;padding:1rem;border-radius:6px;text-align:center}}
702
+ .stat b{{font-size:1.8rem;display:block;color:#00ffdc}}
703
+ .badge{{display:inline-block;padding:.15rem .5rem;border-radius:3px;
704
+ font-size:.75rem;background:#00ffdc;color:#0d0d1a;font-weight:bold}}
705
+ </style></head><body>
706
+ <h1>⚡ Krea-2 API</h1>
707
+ <p class="subtitle">AI image generation as a service — anonymous, proxy-load-balanced, agent-ready</p>
708
+
709
+ <div class="stats">
710
+ <div class="stat"><b>{stats['total']}</b>proxies</div>
711
+ <div class="stat"><b>{stats['good']}</b>good</div>
712
+ <div class="stat"><b>{n_jobs}</b>total jobs</div>
713
+ <div class="stat"><b>{n_running}</b>running</div>
714
+ </div>
715
+
716
+ <h2>🔌 Quick Start</h2>
717
+
718
+ <div class="endpoint">
719
+ <span class="method">GET</span> <code>/generate?prompt=a+cyberpunk+cat</code>
720
+ <span class="badge">SYNC</span>
721
+ <p>Blocca fino al completamento (~5-30s), ritorna JSON con URL immagine.</p>
722
+ </div>
723
+
724
+ <div class="endpoint">
725
+ <span class="method">GET</span> <code>/generate?prompt=...&async=true</code>
726
+ <span class="badge">ASYNC</span>
727
+ <p>Ritorna subito <code>job_id</code>, poi polling con <code>/jobs/{{job_id}}</code>.</p>
728
+ </div>
729
+
730
+ <div class="endpoint">
731
+ <span class="method">GET</span> <code>/generate?prompt=...&n=5</code>
732
+ <span class="badge">BATCH</span>
733
+ <p>Genera N varianti in parallelo (max 10).</p>
734
+ </div>
735
+
736
+ <h2>📋 All Endpoints</h2>
737
+ <pre>GET / → this page
738
+ GET /health → health check
739
+ GET /docs → Swagger UI
740
+ GET /openapi.json → OpenAPI schema (for agents)
741
+
742
+ GET /generate → generate image(s)
743
+ GET /batch → batch from prompts list
744
+ GET /jobs → list all jobs
745
+ GET /jobs/{{job_id}} → job status
746
+ GET /jobs/{{job_id}}/result → job result (waits if running)
747
+ GET /image/{{filename}} → download generated PNG
748
+
749
+ GET /stats → proxy pool stats
750
+ GET /admin/refresh → refresh proxy pool
751
+ GET /admin/cleanup → cleanup old jobs</pre>
752
+
753
+ <h2>🎯 Example — Agent tool call</h2>
754
+ <pre>curl "{base}/generate?prompt=sunset&model=Turbo&steps=8"</pre>
755
+
756
+ <pre>{{
757
+ "job_id": "job_1734567890123_abc123",
758
+ "status": "success",
759
+ "prompt": "sunset",
760
+ "result": {{
761
+ "filename": "job_1734567890123_abc123.png",
762
+ "local_url": "{base}/image/job_1734567890123_abc123.png",
763
+ "hf_url": "https://krea-krea-2.hf.space/...",
764
+ "seed": 823641299
765
+ }}
766
+ }}</pre>
767
+
768
+ <p style="margin-top:2rem;color:#666;text-align:center">
769
+ <a href="/docs">Swagger docs</a> · <a href="/openapi.json">OpenAPI</a> · <a href="/stats">Stats</a>
770
+ </p>
771
+ </body></html>
772
+ """
773
+
774
+
775
+ @app.get("/health")
776
+ def health():
777
+ """Health check per load balancer / orchestrator."""
778
+ return {
779
+ "status": "ok",
780
+ "service": "krea-2-api",
781
+ "version": "1.0.0",
782
+ "timestamp": datetime.utcnow().isoformat() + "Z",
783
+ "uptime_s": time.time() - START_TIME,
784
+ }
785
+
786
+
787
+ # ─── GENERATE (main endpoint) ──────────────────────────────────
788
+
789
+ @app.get("/generate")
790
+ def generate(
791
+ request: Request,
792
+ background_tasks: BackgroundTasks,
793
+ prompt: str = Query(..., description="Text prompt to generate image from",
794
+ min_length=1, max_length=2000, example="a cyberpunk cat"),
795
+ negative_prompt: str = Query("", description="What to avoid in generation"),
796
+ model: Literal["Turbo", "Raw"] = Query("Turbo",
797
+ description="Turbo = fast (8 steps), Raw = quality (more steps)"),
798
+ steps: int = Query(8, ge=1, le=50,
799
+ description="Denoising steps (Turbo: 4-8, Raw: 20-50)"),
800
+ guidance: float = Query(0.0, ge=0.0, le=10.0,
801
+ description="Classifier-free guidance scale"),
802
+ width: int = Query(1024, ge=512, le=2048),
803
+ height: int = Query(1024, ge=512, le=2048),
804
+ resolution: Optional[Literal["square","portrait","landscape","square2k"]] = Query(
805
+ None, description="Preset resolution (overrides width/height)"),
806
+ seed: int = Query(0, ge=0, le=2147483647,
807
+ description="Random seed (0 = random for each generation)"),
808
+ n: int = Query(1, ge=1, le=MAX_PARALLEL_JOBS,
809
+ description=f"Number of images to generate in parallel (max {MAX_PARALLEL_JOBS})"),
810
+ async_mode: bool = Query(False, alias="async",
811
+ description="If true, return job_id immediately; poll /jobs/{id} for result"),
812
+ workers: int = Query(DEFAULT_WORKERS, ge=10, le=200,
813
+ description="Proxy race workers per job (higher = faster but heavier)"),
814
+ ):
815
+ """
816
+ **Generate 1 or more AI images from a text prompt.**
817
+
818
+ Ideal for agentic AI tools. Two modes:
819
+ - `async=false` (default): wait for completion, return full result
820
+ - `async=true`: return `job_id`, poll `/jobs/{job_id}` for status
821
+
822
+ Batch: use `n=5` to generate 5 variants of the same prompt in parallel.
823
+ """
824
+ base_url = get_base_url(request)
825
+
826
+ try:
827
+ jobs = []
828
+ for i in range(n):
829
+ job = build_job(
830
+ prompt=prompt, negative_prompt=negative_prompt,
831
+ model=model, steps=steps, guidance=guidance,
832
+ width=width, height=height,
833
+ resolution=resolution,
834
+ seed=(seed if seed > 0 else None),
835
+ )
836
+ jobs.append(job)
837
+ except ValueError as e:
838
+ raise HTTPException(status_code=400, detail=str(e))
839
+
840
+ if async_mode:
841
+ # Lancia in background, ritorna subito i job_ids
842
+ for job in jobs:
843
+ background_tasks.add_task(execute_job, job, workers, base_url)
844
+ return JSONResponse({
845
+ "status": "queued",
846
+ "job_ids": [j.job_id for j in jobs],
847
+ "count": len(jobs),
848
+ "poll_urls": [f"{base_url}/jobs/{j.job_id}" for j in jobs],
849
+ "message": f"Poll GET /jobs/{{job_id}} to check status",
850
+ })
851
+
852
+ # Sync mode: aspetta tutti
853
+ if len(jobs) == 1:
854
+ execute_job(jobs[0], workers, base_url)
855
+ job = jobs[0]
856
+ if job.status == "success":
857
+ return job.to_public_dict()
858
+ raise HTTPException(status_code=500, detail=job.to_public_dict())
859
+
860
+ # Batch sync
861
+ with ThreadPoolExecutor(max_workers=n) as pool:
862
+ futs = {pool.submit(execute_job, j, workers, base_url): j for j in jobs}
863
+ for f in as_completed(futs): pass
864
+
865
+ results = [j.to_public_dict() for j in jobs]
866
+ n_ok = sum(1 for j in jobs if j.status == "success")
867
+ return {
868
+ "status": "batch_complete",
869
+ "total": len(jobs),
870
+ "success": n_ok,
871
+ "failed": len(jobs) - n_ok,
872
+ "results": results,
873
+ }
874
+
875
+
876
+ @app.get("/batch")
877
+ def batch(
878
+ request: Request,
879
+ background_tasks: BackgroundTasks,
880
+ prompts: str = Query(...,
881
+ description="Prompts separated by '|' (max 10)",
882
+ example="a cat|a dog|a bird"),
883
+ model: Literal["Turbo", "Raw"] = Query("Turbo"),
884
+ steps: int = Query(8, ge=1, le=50),
885
+ guidance: float = Query(0.0, ge=0.0, le=10.0),
886
+ resolution: Optional[Literal["square","portrait","landscape","square2k"]] = None,
887
+ async_mode: bool = Query(False, alias="async"),
888
+ workers: int = Query(DEFAULT_WORKERS, ge=10, le=200),
889
+ ):
890
+ """
891
+ **Batch generation** — different prompts in parallel.
892
+
893
+ Separate prompts with `|` character.
894
+ """
895
+ prompt_list = [p.strip() for p in prompts.split("|") if p.strip()]
896
+ if not prompt_list:
897
+ raise HTTPException(400, "No valid prompts provided")
898
+ if len(prompt_list) > MAX_PARALLEL_JOBS:
899
+ raise HTTPException(400, f"Max {MAX_PARALLEL_JOBS} prompts per batch")
900
+
901
+ base_url = get_base_url(request)
902
+
903
+ try:
904
+ jobs = [build_job(
905
+ prompt=p, model=model, steps=steps, guidance=guidance,
906
+ resolution=resolution
907
+ ) for p in prompt_list]
908
+ except ValueError as e:
909
+ raise HTTPException(400, str(e))
910
+
911
+ if async_mode:
912
+ for job in jobs:
913
+ background_tasks.add_task(execute_job, job, workers, base_url)
914
+ return {
915
+ "status": "queued",
916
+ "count": len(jobs),
917
+ "job_ids": [j.job_id for j in jobs],
918
+ "poll_urls": [f"{base_url}/jobs/{j.job_id}" for j in jobs],
919
+ }
920
+
921
+ with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
922
+ futs = {pool.submit(execute_job, j, workers, base_url): j for j in jobs}
923
+ for f in as_completed(futs): pass
924
+
925
+ return {
926
+ "status": "batch_complete",
927
+ "total": len(jobs),
928
+ "success": sum(1 for j in jobs if j.status == "success"),
929
+ "results": [j.to_public_dict() for j in jobs],
930
+ }
931
+
932
+
933
+ # ─── JOBS ────────────────────────────────────────────────────
934
+
935
+ @app.get("/jobs")
936
+ def list_jobs(
937
+ status: Optional[Literal["pending","running","success","failed"]] = None,
938
+ limit: int = Query(50, ge=1, le=500),
939
+ ):
940
+ """List all jobs (optionally filtered by status)."""
941
+ with JOBS_LOCK:
942
+ items = list(JOBS.values())
943
+ items.sort(key=lambda j: j.created_at, reverse=True)
944
+ if status:
945
+ items = [j for j in items if j.status == status]
946
+ items = items[:limit]
947
+ return {
948
+ "total": len(JOBS),
949
+ "returned": len(items),
950
+ "jobs": [j.to_public_dict() for j in items],
951
+ }
952
+
953
+
954
+ @app.get("/jobs/{job_id}")
955
+ def get_job(job_id: str):
956
+ """Get status/details of a single job by ID."""
957
+ with JOBS_LOCK:
958
+ job = JOBS.get(job_id)
959
+ if not job:
960
+ raise HTTPException(404, f"Job {job_id} not found")
961
+ return job.to_public_dict()
962
+
963
+
964
+ @app.get("/jobs/{job_id}/result")
965
+ def get_job_result(
966
+ job_id: str,
967
+ wait: bool = Query(True, description="Block until job completes (max 90s)"),
968
+ timeout: int = Query(90, ge=1, le=300),
969
+ ):
970
+ """
971
+ Get the result of a job. If `wait=true` and job is still running,
972
+ blocks until completion or timeout.
973
+ """
974
+ with JOBS_LOCK:
975
+ job = JOBS.get(job_id)
976
+ if not job:
977
+ raise HTTPException(404, f"Job {job_id} not found")
978
+
979
+ if wait and job.status in ("pending", "running"):
980
+ deadline = time.time() + timeout
981
+ while time.time() < deadline and job.status in ("pending", "running"):
982
+ time.sleep(0.5)
983
+ if job.status in ("pending", "running"):
984
+ raise HTTPException(408, "Timeout waiting for job")
985
+
986
+ if job.status == "success":
987
+ return job.to_public_dict()
988
+ raise HTTPException(500, job.to_public_dict())
989
+
990
+
991
+ # ─── IMAGE DOWNLOAD ──────────────────────────────────────────
992
+
993
+ @app.get("/image/{filename}")
994
+ def get_image(filename: str):
995
+ """Download a generated image PNG."""
996
+ # sanitize: solo nomi tipo job_xxx.png
997
+ if not re.match(r"^job_[a-zA-Z0-9_]+\.png$", filename):
998
+ raise HTTPException(400, "Invalid filename")
999
+ file_path = IMAGES_DIR / filename
1000
+ if not file_path.exists():
1001
+ raise HTTPException(404, "Image not found (may have expired)")
1002
+ return FileResponse(file_path, media_type="image/png", filename=filename)
1003
+
1004
+
1005
+ # ─── STATS & ADMIN ───────────────────────────────────────────
1006
+
1007
+ @app.get("/stats")
1008
+ def stats():
1009
+ """Statistics: proxy DB + jobs registry."""
1010
+ db_stats = DB.stats()
1011
+ with JOBS_LOCK:
1012
+ job_stats = {
1013
+ "total": len(JOBS),
1014
+ "pending": sum(1 for j in JOBS.values() if j.status == "pending"),
1015
+ "running": sum(1 for j in JOBS.values() if j.status == "running"),
1016
+ "success": sum(1 for j in JOBS.values() if j.status == "success"),
1017
+ "failed": sum(1 for j in JOBS.values() if j.status == "failed"),
1018
+ }
1019
+ return {
1020
+ "proxy_db": db_stats,
1021
+ "jobs": job_stats,
1022
+ "used_proxies_now": len(USED_PROXIES),
1023
+ "uptime_s": time.time() - START_TIME,
1024
+ }
1025
+
1026
+
1027
+ @app.get("/admin/refresh")
1028
+ def admin_refresh():
1029
+ """Force refresh of proxy pool from ProxyScrape."""
1030
+ n = refresh_proxy_pool()
1031
+ return {"status": "ok", "new_proxies_fetched": n, "db_stats": DB.stats()}
1032
+
1033
+
1034
+ @app.get("/admin/cleanup")
1035
+ def admin_cleanup():
1036
+ """Clean up old jobs from registry."""
1037
+ n = cleanup_old_jobs()
1038
+ return {"status": "ok", "jobs_removed": n, "jobs_remaining": len(JOBS)}
1039
+
1040
+
1041
+ # ═══════════════════════════════════════════════════════════════
1042
+ # OPENAPI TOOL SCHEMA (per agent AI)
1043
+ # ═══════════════════════════════════════════════════════════════
1044
+
1045
+ @app.get("/tool-schema")
1046
+ def tool_schema(request: Request):
1047
+ """
1048
+ OpenAI/Anthropic-compatible tool schema for agent integration.
1049
+ Copy this JSON directly into your agent's tool definitions.
1050
+ """
1051
+ base = get_base_url(request)
1052
+ return {
1053
+ "openai_function_call": {
1054
+ "type": "function",
1055
+ "function": {
1056
+ "name": "generate_image",
1057
+ "description": (
1058
+ "Generate an AI image from a text description using Krea-2 model. "
1059
+ "Returns a URL where the image can be downloaded. "
1060
+ "Fast (5-30 seconds), no login required."
1061
+ ),
1062
+ "parameters": {
1063
+ "type": "object",
1064
+ "properties": {
1065
+ "prompt": {"type": "string",
1066
+ "description": "Detailed text description of the image to generate"},
1067
+ "model": {"type": "string", "enum": ["Turbo", "Raw"],
1068
+ "description": "Turbo=fast, Raw=high quality", "default": "Turbo"},
1069
+ "steps": {"type": "integer", "minimum": 1, "maximum": 50,
1070
+ "description": "Denoising steps", "default": 8},
1071
+ "resolution": {"type": "string",
1072
+ "enum": ["square", "portrait", "landscape", "square2k"],
1073
+ "default": "square"},
1074
+ "n": {"type": "integer", "minimum": 1, "maximum": 10,
1075
+ "description": "Number of variants", "default": 1},
1076
+ },
1077
+ "required": ["prompt"],
1078
+ },
1079
+ },
1080
+ "api_endpoint": f"GET {base}/generate",
1081
+ },
1082
+ "anthropic_tool": {
1083
+ "name": "generate_image",
1084
+ "description": "Generate AI images from text prompts via Krea-2",
1085
+ "input_schema": {
1086
+ "type": "object",
1087
+ "properties": {
1088
+ "prompt": {"type": "string"},
1089
+ "model": {"type": "string", "enum": ["Turbo", "Raw"]},
1090
+ "steps": {"type": "integer"},
1091
+ "resolution": {"type": "string",
1092
+ "enum": ["square", "portrait", "landscape", "square2k"]},
1093
+ "n": {"type": "integer"},
1094
+ },
1095
+ "required": ["prompt"],
1096
+ },
1097
+ "api_endpoint": f"GET {base}/generate",
1098
+ },
1099
+ "langchain_tool_example": (
1100
+ "from langchain.tools import tool\n"
1101
+ "import requests\n\n"
1102
+ "@tool\n"
1103
+ "def generate_image(prompt: str, n: int = 1) -> str:\n"
1104
+ " '''Generate AI images. Returns URLs.'''\n"
1105
+ f" r = requests.get('{base}/generate', params={{'prompt': prompt, 'n': n}})\n"
1106
+ " data = r.json()\n"
1107
+ " if data['status'] == 'success':\n"
1108
+ " return data['result']['local_url']\n"
1109
+ " return [j['result']['local_url'] for j in data['results']]"
1110
+ ),
1111
+ }
1112
+
1113
+
1114
+ # ════════════════════════════���══════════════════════════════════
1115
+ # STARTUP
1116
+ # ═══════════════════════════════════════════════════════════════
1117
+
1118
+ START_TIME = time.time()
1119
+
1120
+
1121
+ @app.on_event("startup")
1122
+ async def startup():
1123
+ """Refresh iniziale del pool proxy."""
1124
+ print(f"\n{'='*60}")
1125
+ print(f" ⚡ KREA-2 API STARTING")
1126
+ print(f"{'='*60}")
1127
+ print(f" → Refreshing proxy pool from ProxyScrape...")
1128
+ n = refresh_proxy_pool()
1129
+ s = DB.stats()
1130
+ print(f" → {n} new proxies fetched")
1131
+ print(f" → DB: {s['total']} total ({s['good']} good, {s['banned']} banned)")
1132
+ print(f" → Images directory: {IMAGES_DIR.absolute()}")
1133
+ print(f" → Ready ✓\n")
1134
+
1135
+ # Cleanup periodico background
1136
+ async def periodic_cleanup():
1137
+ while True:
1138
+ await asyncio.sleep(600) # ogni 10min
1139
+ n = cleanup_old_jobs()
1140
+ if n > 0:
1141
+ print(f" [cleanup] removed {n} old jobs")
1142
+
1143
+ asyncio.create_task(periodic_cleanup())
1144
+
1145
+
1146
+ # ═══════════════════════════════════════════════════════════════
1147
+ # MAIN
1148
+ # ═══════════════════════════════════════════════════════════════
1149
+
1150
+ def main():
1151
+ parser = argparse.ArgumentParser(description="Krea-2 REST API server")
1152
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host")
1153
+ parser.add_argument("--port", type=int, default=7860, help="Bind port")
1154
+ parser.add_argument("--reload", action="store_true", help="Auto-reload on changes")
1155
+ parser.add_argument("--workers", type=int, default=1,
1156
+ help="Uvicorn workers (>1 disabilita stato in-memory condiviso)")
1157
+ args = parser.parse_args()
1158
+
1159
+ if os.name == "nt": os.system("")
1160
+
1161
+ print(f"\n Starting on http://{args.host}:{args.port}")
1162
+ print(f" Docs: http://{args.host}:{args.port}/docs")
1163
+ print(f" OpenAPI: http://{args.host}:{args.port}/openapi.json\n")
1164
+
1165
+ uvicorn.run(
1166
+ "api:app" if args.reload else app,
1167
+ host=args.host,
1168
+ port=args.port,
1169
+ reload=args.reload,
1170
+ workers=args.workers if not args.reload else 1,
1171
+ log_level="info",
1172
+ )
1173
+
1174
+
1175
+ if __name__ == "__main__":
1176
+ import os
1177
+ if os.name == "nt": os.system("")
1178
+ uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ curl_cffi>=0.7.0
2
+ fastapi>=0.104.0
3
+ uvicorn[standard]>=0.24.0