zer0-image-generator 0.8.0 → 0.9.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +14 -6
- data/lib/zer0_image_generator/all.rb +3 -2
- data/lib/zer0_image_generator/claude/client.rb +3 -2
- data/lib/zer0_image_generator/cli.rb +33 -15
- data/lib/zer0_image_generator/command.rb +2 -2
- data/lib/zer0_image_generator/constants.rb +11 -0
- data/lib/zer0_image_generator/facade.rb +248 -0
- data/lib/zer0_image_generator/grok/client.rb +92 -0
- data/lib/zer0_image_generator/http.rb +14 -0
- data/lib/zer0_image_generator/preview_generator.py +427 -43
- data/lib/zer0_image_generator/providers/base.rb +12 -3
- data/lib/zer0_image_generator/providers/openai.rb +3 -1
- data/lib/zer0_image_generator/providers/xai.rb +27 -1
- data/lib/zer0_image_generator/providers/xai_auth.rb +235 -13
- data/lib/zer0_image_generator/runner.rb +11 -9
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +3 -1
|
@@ -73,6 +73,7 @@ import base64
|
|
|
73
73
|
import functools
|
|
74
74
|
import io
|
|
75
75
|
import json
|
|
76
|
+
import math
|
|
76
77
|
import os
|
|
77
78
|
import re
|
|
78
79
|
import shutil
|
|
@@ -83,6 +84,7 @@ import threading
|
|
|
83
84
|
import time
|
|
84
85
|
import uuid
|
|
85
86
|
import urllib.error
|
|
87
|
+
import urllib.parse
|
|
86
88
|
import urllib.request
|
|
87
89
|
import zlib
|
|
88
90
|
import xml.etree.ElementTree as ET
|
|
@@ -243,6 +245,17 @@ style, or contains text/glitches — minor taste differences are not grounds
|
|
|
243
245
|
for revision."""
|
|
244
246
|
|
|
245
247
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
248
|
+
JPEG_SIGNATURE = b"\xff\xd8\xff"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def image_media_type(data: bytes) -> str:
|
|
252
|
+
"""The MIME type a raster's own bytes declare. Providers do not all answer
|
|
253
|
+
PNG (xAI Imagine answers JPEG), and vision APIs check the label."""
|
|
254
|
+
if data[:3] == JPEG_SIGNATURE:
|
|
255
|
+
return "image/jpeg"
|
|
256
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
257
|
+
return "image/webp"
|
|
258
|
+
return "image/png"
|
|
246
259
|
|
|
247
260
|
# Pause after each successful non-dry generation (Bash parity: polite pacing
|
|
248
261
|
# between paid API calls). Tests set this to 0.
|
|
@@ -428,6 +441,12 @@ class HttpStatusError(Exception):
|
|
|
428
441
|
return self.body.decode("utf-8", "replace")[:500]
|
|
429
442
|
|
|
430
443
|
|
|
444
|
+
# Sent on every request that does not name its own client. api.x.ai and
|
|
445
|
+
# auth.x.ai sit behind Cloudflare, whose bot rules answer urllib's stock
|
|
446
|
+
# `Python-urllib/3.x` with a 403 page or `error code: 1010`.
|
|
447
|
+
HTTP_USER_AGENT = "zer0-image-generator"
|
|
448
|
+
|
|
449
|
+
|
|
431
450
|
def http_request(
|
|
432
451
|
url: str,
|
|
433
452
|
method: str = "GET",
|
|
@@ -438,6 +457,8 @@ def http_request(
|
|
|
438
457
|
req = urllib.request.Request(url, data=data, method=method)
|
|
439
458
|
for key, value in (headers or {}).items():
|
|
440
459
|
req.add_header(key, value)
|
|
460
|
+
if not req.has_header("User-agent"): # urllib stores names capitalize()d
|
|
461
|
+
req.add_header("User-Agent", HTTP_USER_AGENT)
|
|
441
462
|
try:
|
|
442
463
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
443
464
|
return resp.status, dict(resp.headers.items()), resp.read()
|
|
@@ -2073,6 +2094,7 @@ class AnthropicClient:
|
|
|
2073
2094
|
system_blocks = [{"type": "text", "text": system_text}]
|
|
2074
2095
|
if self.mode == "oauth":
|
|
2075
2096
|
system_blocks.insert(0, {"type": "text", "text": CLAUDE_CODE_SYSTEM_PROMPT})
|
|
2097
|
+
image_bytes = image_path.read_bytes()
|
|
2076
2098
|
payload: Dict[str, Any] = {
|
|
2077
2099
|
"model": model,
|
|
2078
2100
|
"max_tokens": max_tokens,
|
|
@@ -2082,8 +2104,8 @@ class AnthropicClient:
|
|
|
2082
2104
|
"role": "user",
|
|
2083
2105
|
"content": [
|
|
2084
2106
|
{"type": "image", "source": {
|
|
2085
|
-
"type": "base64", "media_type":
|
|
2086
|
-
"data": base64.b64encode(
|
|
2107
|
+
"type": "base64", "media_type": image_media_type(image_bytes),
|
|
2108
|
+
"data": base64.b64encode(image_bytes).decode("ascii"),
|
|
2087
2109
|
}},
|
|
2088
2110
|
{"type": "text", "text": user_text},
|
|
2089
2111
|
],
|
|
@@ -2217,6 +2239,7 @@ class RunContext:
|
|
|
2217
2239
|
project_root: Path
|
|
2218
2240
|
env: Dict[str, str]
|
|
2219
2241
|
anthropic: Optional[AnthropicClient] = None
|
|
2242
|
+
grok: Optional["GrokClient"] = None
|
|
2220
2243
|
slug: str = ""
|
|
2221
2244
|
# The page's own context (slug/section/title/categories/description/body),
|
|
2222
2245
|
# set per file by the runner. The local renderer derives BOTH composition
|
|
@@ -2229,6 +2252,14 @@ class RunContext:
|
|
|
2229
2252
|
self.anthropic = AnthropicClient(self.env)
|
|
2230
2253
|
return self.anthropic
|
|
2231
2254
|
|
|
2255
|
+
def grok_client(self) -> "GrokClient":
|
|
2256
|
+
if self.grok is None:
|
|
2257
|
+
self.grok = GrokClient(self.env)
|
|
2258
|
+
return self.grok
|
|
2259
|
+
|
|
2260
|
+
def orchestrator(self, engine: str):
|
|
2261
|
+
return self.grok_client() if engine == "xai" else self.claude()
|
|
2262
|
+
|
|
2232
2263
|
|
|
2233
2264
|
def adapt_openai_size_quality(model: str, size: str, quality: str) -> Tuple[str, str]:
|
|
2234
2265
|
"""Historical engine behavior: adapt shared settings per model family."""
|
|
@@ -2303,7 +2334,8 @@ class OpenAIProvider(Provider):
|
|
|
2303
2334
|
# gpt-image-2 does not accept input_fidelity (historical behavior).
|
|
2304
2335
|
if model != "gpt-image-2":
|
|
2305
2336
|
fields["input_fidelity"] = settings.enhance_fidelity
|
|
2306
|
-
|
|
2337
|
+
image_bytes = image_path.read_bytes()
|
|
2338
|
+
files = [("image[]", image_path.name, image_bytes, image_media_type(image_bytes))]
|
|
2307
2339
|
out_path = out_path or image_path
|
|
2308
2340
|
debug(f"OpenAI edit: model={model} fidelity="
|
|
2309
2341
|
f"{fields.get('input_fidelity', '(omitted)')} format={settings.enhance_format}")
|
|
@@ -2338,12 +2370,14 @@ class OpenAIProvider(Provider):
|
|
|
2338
2370
|
# and a console API key is where the value comes from and what we tell the human
|
|
2339
2371
|
# when it is rejected.
|
|
2340
2372
|
#
|
|
2341
|
-
#
|
|
2342
|
-
#
|
|
2343
|
-
#
|
|
2344
|
-
#
|
|
2345
|
-
#
|
|
2346
|
-
#
|
|
2373
|
+
# Refresh, not login. The engine never MINTS a token — that takes an interactive
|
|
2374
|
+
# login — but an expired token whose store also holds a refresh
|
|
2375
|
+
# token and a client id (Kilo Code's login does) is refreshed at auth.x.ai once,
|
|
2376
|
+
# right before a request needs it, and written back to that store so the login
|
|
2377
|
+
# tool and the next run both see the rotated pair (lifehacker.dev's cover-art
|
|
2378
|
+
# script already refreshes and saves the same way). Expired with nothing to
|
|
2379
|
+
# refresh with, a token is treated as absent and the chain falls back to the
|
|
2380
|
+
# API key.
|
|
2347
2381
|
XAI_API_BASE = "https://api.x.ai/v1"
|
|
2348
2382
|
|
|
2349
2383
|
# OAuth access token straight from the environment. XAI_ first (it matches the
|
|
@@ -2362,6 +2396,7 @@ XAI_API_KEY_ENV = "XAI_API_KEY"
|
|
|
2362
2396
|
# home directory so a test (and a container) can point HOME elsewhere.
|
|
2363
2397
|
XAI_DEFAULT_CREDENTIALS_FILES: Tuple[str, ...] = (
|
|
2364
2398
|
".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json",
|
|
2399
|
+
".local/share/kilo/auth.json",
|
|
2365
2400
|
)
|
|
2366
2401
|
|
|
2367
2402
|
# Credential files nest the token differently per tool; look at the top level
|
|
@@ -2378,6 +2413,17 @@ XAI_EXPIRY_LEEWAY = 60.0
|
|
|
2378
2413
|
XAI_AUTH_MODE_ENV = "XAI_AUTH"
|
|
2379
2414
|
XAI_AUTH_MODES: Tuple[str, ...] = ("auto", "oauth", "api_key")
|
|
2380
2415
|
|
|
2416
|
+
# The refresh grant's endpoint, as auth.x.ai's OIDC discovery document
|
|
2417
|
+
# (/.well-known/openid-configuration) publishes it.
|
|
2418
|
+
XAI_TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
|
|
2419
|
+
XAI_REFRESH_TOKEN_KEYS: Tuple[str, ...] = ("refresh_token", "refresh")
|
|
2420
|
+
XAI_EXPIRY_KEYS: Tuple[str, ...] = ("expires_at", "expiry", "expiration", "expires")
|
|
2421
|
+
XAI_ISSUED_KEYS: Tuple[str, ...] = ("created_at", "obtained_at", "issued_at", "issued")
|
|
2422
|
+
# Workers share one store: the first to find the token expired refreshes it,
|
|
2423
|
+
# the rest wait and read the result. A refresh token may rotate on use, so two
|
|
2424
|
+
# concurrent refreshes would leave one worker holding a dead token.
|
|
2425
|
+
_XAI_REFRESH_LOCK = threading.Lock()
|
|
2426
|
+
|
|
2381
2427
|
XAI_MISSING_HINT = (
|
|
2382
2428
|
"The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n"
|
|
2383
2429
|
" 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n"
|
|
@@ -2396,6 +2442,9 @@ class XAICredential:
|
|
|
2396
2442
|
mode: str # "oauth" | "api_key" — which rung of the chain this is
|
|
2397
2443
|
token: str
|
|
2398
2444
|
source: str # human label for logs and fallback warnings
|
|
2445
|
+
# Set when the token has expired but its store can refresh it: the token is
|
|
2446
|
+
# empty until xai_ready_credential() refreshes it, right before use.
|
|
2447
|
+
refresh_path: Optional[str] = None
|
|
2399
2448
|
|
|
2400
2449
|
def headers(self) -> Dict[str, str]:
|
|
2401
2450
|
return {"Authorization": f"Bearer {self.token}"}
|
|
@@ -2471,7 +2520,11 @@ def _xai_credential_files(env: Dict[str, str]) -> List[str]:
|
|
|
2471
2520
|
home = _xai_home_dir(env)
|
|
2472
2521
|
if not home:
|
|
2473
2522
|
return []
|
|
2474
|
-
|
|
2523
|
+
paths = [os.path.join(home, rel) for rel in XAI_DEFAULT_CREDENTIALS_FILES]
|
|
2524
|
+
kilo = env.get("KILO_AUTH_PATH") or ""
|
|
2525
|
+
if kilo:
|
|
2526
|
+
paths.insert(0, kilo)
|
|
2527
|
+
return paths
|
|
2475
2528
|
|
|
2476
2529
|
|
|
2477
2530
|
def _xai_home_dir(env: Dict[str, str]) -> Optional[str]:
|
|
@@ -2507,20 +2560,189 @@ def _xai_file_credential(path: str) -> Optional[XAICredential]:
|
|
|
2507
2560
|
debug(f"xAI OAuth: no access_token in {path}; ignoring")
|
|
2508
2561
|
return None
|
|
2509
2562
|
if _xai_expired(node):
|
|
2510
|
-
|
|
2563
|
+
if _xai_refresh_token(node) and _xai_client_id(node):
|
|
2564
|
+
return XAICredential("oauth", "",
|
|
2565
|
+
f"Grok OAuth token ({path}; expired, refreshed on use)",
|
|
2566
|
+
refresh_path=path)
|
|
2567
|
+
debug(f"xAI OAuth: the token in {path} has expired and holds nothing to "
|
|
2568
|
+
f"refresh it with; re-run your Grok login")
|
|
2511
2569
|
return None
|
|
2512
2570
|
return XAICredential("oauth", str(node["access_token"]),
|
|
2513
2571
|
f"Grok OAuth token ({path})")
|
|
2514
2572
|
|
|
2515
2573
|
|
|
2574
|
+
def xai_ready_credential(credential: XAICredential) -> XAICredential:
|
|
2575
|
+
"""The credential ready to send: itself, or for an expired OAuth token its
|
|
2576
|
+
store can refresh, the refreshed one. Raises RuntimeError on a failed refresh."""
|
|
2577
|
+
if not credential.refresh_path:
|
|
2578
|
+
return credential
|
|
2579
|
+
return _xai_refresh_file_credential(credential.refresh_path)
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _xai_refresh_file_credential(path: str) -> XAICredential:
|
|
2583
|
+
with _XAI_REFRESH_LOCK:
|
|
2584
|
+
try:
|
|
2585
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
2586
|
+
except (OSError, ValueError) as exc:
|
|
2587
|
+
raise RuntimeError(f"xAI OAuth: cannot re-read {path} ({exc})") from None
|
|
2588
|
+
node = _xai_find_token_node(data) if isinstance(data, dict) else None
|
|
2589
|
+
if node is None:
|
|
2590
|
+
raise RuntimeError(f"xAI OAuth: no access token left in {path}")
|
|
2591
|
+
# Another worker may have refreshed the store while this one waited.
|
|
2592
|
+
current = _xai_token_node(data)
|
|
2593
|
+
if current is not None and not _xai_expired(current):
|
|
2594
|
+
return XAICredential("oauth", str(current["access_token"]),
|
|
2595
|
+
f"Grok OAuth token ({path})")
|
|
2596
|
+
refresh, client_id = _xai_refresh_token(node), _xai_client_id(node)
|
|
2597
|
+
if not refresh or not client_id:
|
|
2598
|
+
raise RuntimeError(f"xAI OAuth: the token in {path} cannot be refreshed")
|
|
2599
|
+
fresh = _xai_request_refresh(refresh, client_id)
|
|
2600
|
+
_xai_store_refreshed(node, fresh, time.time())
|
|
2601
|
+
try:
|
|
2602
|
+
_xai_write_store(path, data)
|
|
2603
|
+
except OSError as exc:
|
|
2604
|
+
# The refresh token may already have rotated, so say so loudly — but
|
|
2605
|
+
# this run still holds a working access token.
|
|
2606
|
+
warn(f"xAI OAuth: refreshed, but could not save the new token to {path} "
|
|
2607
|
+
f"({exc}); the login tool may need a fresh sign-in")
|
|
2608
|
+
debug(f"xAI OAuth: refreshed the token in {path}")
|
|
2609
|
+
return XAICredential("oauth", str(fresh["access_token"]),
|
|
2610
|
+
f"Grok OAuth token ({path}; refreshed)")
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
def _xai_request_refresh(refresh_token: str, client_id: str) -> Dict[str, Any]:
|
|
2614
|
+
"""POST the refresh grant. Never retried: a refresh token that rotated on a
|
|
2615
|
+
request whose response was lost is already spent."""
|
|
2616
|
+
body = urllib.parse.urlencode({
|
|
2617
|
+
"grant_type": "refresh_token",
|
|
2618
|
+
"refresh_token": refresh_token,
|
|
2619
|
+
"client_id": client_id,
|
|
2620
|
+
}).encode("utf-8")
|
|
2621
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded",
|
|
2622
|
+
"Accept": "application/json"}
|
|
2623
|
+
try:
|
|
2624
|
+
_, _, raw = http_request(XAI_TOKEN_ENDPOINT, "POST", headers, body, 60)
|
|
2625
|
+
except HttpStatusError as exc:
|
|
2626
|
+
# The OAuth error code (invalid_grant, invalid_client) is safe to show
|
|
2627
|
+
# and says whether a re-login is due; the description may echo input.
|
|
2628
|
+
details = exc.json()
|
|
2629
|
+
code = details.get("error") if isinstance(details, dict) else None
|
|
2630
|
+
reason = f"HTTP {exc.status}: {code}" if _xai_token_string(code) else f"HTTP {exc.status}"
|
|
2631
|
+
raise RuntimeError(f"xAI OAuth refresh failed ({reason})") from None
|
|
2632
|
+
try:
|
|
2633
|
+
data = json.loads(raw.decode("utf-8"))
|
|
2634
|
+
except ValueError:
|
|
2635
|
+
raise RuntimeError("xAI OAuth refresh returned non-JSON") from None
|
|
2636
|
+
if not isinstance(data, dict) or not _xai_token_string(data.get("access_token")):
|
|
2637
|
+
raise RuntimeError("xAI OAuth refresh returned no access_token")
|
|
2638
|
+
return data
|
|
2639
|
+
|
|
2640
|
+
|
|
2641
|
+
def _xai_store_refreshed(node: Dict[str, Any], fresh: Dict[str, Any], now: float) -> None:
|
|
2642
|
+
"""Write the refreshed pair into the store's own node, in its own key names
|
|
2643
|
+
and time units, so the tool that owns the file still reads it."""
|
|
2644
|
+
token_key = "access_token" if _xai_token_string(node.get("access_token")) else "access"
|
|
2645
|
+
node[token_key] = fresh["access_token"]
|
|
2646
|
+
if _xai_token_string(fresh.get("refresh_token")):
|
|
2647
|
+
refresh_key = next((k for k in XAI_REFRESH_TOKEN_KEYS
|
|
2648
|
+
if _xai_token_string(node.get(k))), "refresh_token")
|
|
2649
|
+
node[refresh_key] = fresh["refresh_token"]
|
|
2650
|
+
lifetime = _xai_number(fresh.get("expires_in")) or 3600.0
|
|
2651
|
+
for key in XAI_EXPIRY_KEYS:
|
|
2652
|
+
if _xai_epoch_seconds(node.get(key)) is not None:
|
|
2653
|
+
node[key] = _xai_timestamp_like(node[key], now + lifetime)
|
|
2654
|
+
return
|
|
2655
|
+
if _xai_number(node.get("expires_in")) is not None:
|
|
2656
|
+
node["expires_in"] = int(lifetime)
|
|
2657
|
+
for key in XAI_ISSUED_KEYS:
|
|
2658
|
+
if _xai_epoch_seconds(node.get(key)) is not None:
|
|
2659
|
+
node[key] = _xai_timestamp_like(node[key], now)
|
|
2660
|
+
return
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
def _xai_timestamp_like(previous: Any, seconds: float) -> Any:
|
|
2664
|
+
"""`seconds` in the shape `previous` used: epoch ms, epoch s, or ISO-8601."""
|
|
2665
|
+
value = _xai_number(previous)
|
|
2666
|
+
if value is not None:
|
|
2667
|
+
stamp = int(seconds * 1000) if value > 1e11 else int(seconds)
|
|
2668
|
+
return str(stamp) if isinstance(previous, str) else stamp
|
|
2669
|
+
return datetime.fromtimestamp(int(seconds), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
2670
|
+
|
|
2671
|
+
|
|
2672
|
+
def _xai_write_store(path: str, data: Dict[str, Any]) -> None:
|
|
2673
|
+
"""Replace the store atomically, keeping its permissions (it holds secrets)."""
|
|
2674
|
+
target = Path(os.path.realpath(path))
|
|
2675
|
+
mode = target.stat().st_mode & 0o777
|
|
2676
|
+
tmp = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
|
2677
|
+
try:
|
|
2678
|
+
# Created owner-only: the token must never sit world-readable, even briefly.
|
|
2679
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
2680
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
2681
|
+
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
2682
|
+
os.chmod(tmp, mode)
|
|
2683
|
+
os.replace(tmp, target)
|
|
2684
|
+
finally:
|
|
2685
|
+
if tmp.exists():
|
|
2686
|
+
tmp.unlink()
|
|
2687
|
+
|
|
2688
|
+
|
|
2689
|
+
def _xai_refresh_token(node: Dict[str, Any]) -> Optional[str]:
|
|
2690
|
+
for key in XAI_REFRESH_TOKEN_KEYS:
|
|
2691
|
+
if _xai_token_string(node.get(key)):
|
|
2692
|
+
return node[key]
|
|
2693
|
+
return None
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
def _xai_client_id(node: Dict[str, Any]) -> Optional[str]:
|
|
2697
|
+
"""The OAuth client the token was issued to: the store's own record, else
|
|
2698
|
+
the access token's JWT claims (client_id, then a string or first aud)."""
|
|
2699
|
+
if _xai_token_string(node.get("client_id")):
|
|
2700
|
+
return node["client_id"]
|
|
2701
|
+
access = node.get("access_token") if _xai_token_string(node.get("access_token")) \
|
|
2702
|
+
else node.get("access")
|
|
2703
|
+
claims = _xai_jwt_claims(access)
|
|
2704
|
+
if _xai_token_string(claims.get("client_id")):
|
|
2705
|
+
return claims["client_id"]
|
|
2706
|
+
aud = claims.get("aud")
|
|
2707
|
+
if isinstance(aud, list):
|
|
2708
|
+
aud = next((a for a in aud if _xai_token_string(a)), None)
|
|
2709
|
+
return aud if _xai_token_string(aud) else None
|
|
2710
|
+
|
|
2711
|
+
|
|
2712
|
+
def _xai_jwt_claims(token: Any) -> Dict[str, Any]:
|
|
2713
|
+
parts = token.split(".") if isinstance(token, str) else []
|
|
2714
|
+
if len(parts) < 2:
|
|
2715
|
+
return {}
|
|
2716
|
+
try:
|
|
2717
|
+
padded = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
2718
|
+
claims = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
2719
|
+
except (ValueError, UnicodeDecodeError):
|
|
2720
|
+
return {}
|
|
2721
|
+
return claims if isinstance(claims, dict) else {}
|
|
2722
|
+
|
|
2723
|
+
|
|
2516
2724
|
def _xai_token_node(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
2517
2725
|
"""The dict actually holding `access_token`: the document itself, or one of
|
|
2518
|
-
the containers a login tool nests it under."""
|
|
2519
|
-
|
|
2726
|
+
the containers a login tool nests it under. Kilo's store uses `access`."""
|
|
2727
|
+
node = _xai_find_token_node(data)
|
|
2728
|
+
if node is None:
|
|
2729
|
+
return None
|
|
2730
|
+
if _xai_token_string(node.get("access_token")):
|
|
2731
|
+
return node
|
|
2732
|
+
out = dict(node)
|
|
2733
|
+
out["access_token"] = node["access"]
|
|
2734
|
+
return out
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
def _xai_find_token_node(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
2738
|
+
if _xai_token_string(data.get("access_token")) or _xai_token_string(data.get("access")):
|
|
2520
2739
|
return data
|
|
2521
2740
|
for key in XAI_NESTED_KEYS:
|
|
2522
2741
|
nested = data.get(key)
|
|
2523
|
-
if isinstance(nested, dict) and
|
|
2742
|
+
if isinstance(nested, dict) and (
|
|
2743
|
+
_xai_token_string(nested.get("access_token"))
|
|
2744
|
+
or _xai_token_string(nested.get("access"))
|
|
2745
|
+
):
|
|
2524
2746
|
return nested
|
|
2525
2747
|
return None
|
|
2526
2748
|
|
|
@@ -2571,6 +2793,28 @@ def _xai_epoch_seconds(raw: Any) -> Optional[float]:
|
|
|
2571
2793
|
return parsed.timestamp()
|
|
2572
2794
|
|
|
2573
2795
|
|
|
2796
|
+
# Aspect ratios the Imagine models take. grok-imagine renders a square unless
|
|
2797
|
+
# told otherwise, which a wide banner then crops; grok-2-image predates the
|
|
2798
|
+
# parameter, so it is sent nothing.
|
|
2799
|
+
XAI_IMAGINE_PREFIX = "grok-imagine"
|
|
2800
|
+
XAI_ASPECT_RATIOS: Tuple[Tuple[str, float], ...] = (
|
|
2801
|
+
("1:1", 1.0), ("3:2", 1.5), ("2:3", 2 / 3), ("4:3", 4 / 3),
|
|
2802
|
+
("3:4", 0.75), ("16:9", 16 / 9), ("9:16", 9 / 16),
|
|
2803
|
+
)
|
|
2804
|
+
|
|
2805
|
+
|
|
2806
|
+
def xai_aspect_ratio(model: str, size: str) -> Optional[str]:
|
|
2807
|
+
"""The Imagine aspect ratio nearest the configured WIDTHxHEIGHT size, or None
|
|
2808
|
+
for a non-Imagine model or a size with no dimensions (e.g. `auto`)."""
|
|
2809
|
+
if not (model or "").strip().lower().startswith(XAI_IMAGINE_PREFIX):
|
|
2810
|
+
return None
|
|
2811
|
+
match = re.fullmatch(r"\s*(\d+)\s*[xX]\s*(\d+)\s*", size or "")
|
|
2812
|
+
if not match or not int(match.group(1)) or not int(match.group(2)):
|
|
2813
|
+
return None
|
|
2814
|
+
wanted = math.log(int(match.group(1)) / int(match.group(2)))
|
|
2815
|
+
return min(XAI_ASPECT_RATIOS, key=lambda item: abs(math.log(item[1]) - wanted))[0]
|
|
2816
|
+
|
|
2817
|
+
|
|
2574
2818
|
def _xai_number(raw: Any) -> Optional[float]:
|
|
2575
2819
|
# bool is an int subclass; a JSON true/false is not a timestamp.
|
|
2576
2820
|
if isinstance(raw, bool):
|
|
@@ -2585,6 +2829,96 @@ def _xai_number(raw: Any) -> Optional[float]:
|
|
|
2585
2829
|
return None
|
|
2586
2830
|
|
|
2587
2831
|
|
|
2832
|
+
class GrokClient:
|
|
2833
|
+
"""OpenAI-compatible chat client for api.x.ai.
|
|
2834
|
+
|
|
2835
|
+
Duck-typed against AnthropicClient (complete / complete_vision / available /
|
|
2836
|
+
describe) so analyze/review can run on Grok with the same OAuth token the
|
|
2837
|
+
image renderer already uses.
|
|
2838
|
+
"""
|
|
2839
|
+
|
|
2840
|
+
DEFAULT_MODEL = "grok-4"
|
|
2841
|
+
|
|
2842
|
+
def __init__(self, env: Optional[Dict[str, str]] = None) -> None:
|
|
2843
|
+
self.env = dict(os.environ if env is None else env)
|
|
2844
|
+
chain = xai_credential_chain(self.env)
|
|
2845
|
+
self.credential = chain[0] if chain else None
|
|
2846
|
+
|
|
2847
|
+
def available(self) -> bool:
|
|
2848
|
+
return self.credential is not None
|
|
2849
|
+
|
|
2850
|
+
def describe(self) -> str:
|
|
2851
|
+
return self.credential.source if self.credential else "none"
|
|
2852
|
+
|
|
2853
|
+
def complete(
|
|
2854
|
+
self,
|
|
2855
|
+
system_text: str,
|
|
2856
|
+
user_text: str,
|
|
2857
|
+
model: str = DEFAULT_MODEL,
|
|
2858
|
+
max_tokens: int = 2048,
|
|
2859
|
+
effort: str = "",
|
|
2860
|
+
) -> str:
|
|
2861
|
+
if self.credential is None:
|
|
2862
|
+
raise RuntimeError("no xAI credential configured")
|
|
2863
|
+
payload = {
|
|
2864
|
+
"model": self._resolve_model(model),
|
|
2865
|
+
"messages": [
|
|
2866
|
+
{"role": "system", "content": system_text},
|
|
2867
|
+
{"role": "user", "content": user_text},
|
|
2868
|
+
],
|
|
2869
|
+
"max_tokens": max_tokens,
|
|
2870
|
+
}
|
|
2871
|
+
return self._message_text(self._api_call(payload, "xAI chat"))
|
|
2872
|
+
|
|
2873
|
+
def complete_vision(
|
|
2874
|
+
self,
|
|
2875
|
+
system_text: str,
|
|
2876
|
+
user_text: str,
|
|
2877
|
+
image_path: Path,
|
|
2878
|
+
model: str = DEFAULT_MODEL,
|
|
2879
|
+
max_tokens: int = 2048,
|
|
2880
|
+
effort: str = "",
|
|
2881
|
+
) -> str:
|
|
2882
|
+
if self.credential is None:
|
|
2883
|
+
raise RuntimeError("no xAI credential configured")
|
|
2884
|
+
raw = Path(image_path).read_bytes()
|
|
2885
|
+
b64 = base64.b64encode(raw).decode("ascii")
|
|
2886
|
+
payload = {
|
|
2887
|
+
"model": self._resolve_model(model),
|
|
2888
|
+
"messages": [
|
|
2889
|
+
{"role": "system", "content": system_text},
|
|
2890
|
+
{"role": "user", "content": [
|
|
2891
|
+
{"type": "image_url",
|
|
2892
|
+
"image_url": {"url": f"data:{image_media_type(raw)};base64,{b64}"}},
|
|
2893
|
+
{"type": "text", "text": user_text},
|
|
2894
|
+
]},
|
|
2895
|
+
],
|
|
2896
|
+
"max_tokens": max_tokens,
|
|
2897
|
+
}
|
|
2898
|
+
return self._message_text(self._api_call(payload, "xAI chat (review)"))
|
|
2899
|
+
|
|
2900
|
+
def _resolve_model(self, model: str) -> str:
|
|
2901
|
+
name = (model or "").strip()
|
|
2902
|
+
if not name or name.startswith("claude"):
|
|
2903
|
+
return self.DEFAULT_MODEL
|
|
2904
|
+
return name
|
|
2905
|
+
|
|
2906
|
+
def _api_call(self, payload: Dict[str, Any], what: str) -> dict:
|
|
2907
|
+
self.credential = xai_ready_credential(self.credential)
|
|
2908
|
+
url = f"{xai_base_url(self.env)}/chat/completions"
|
|
2909
|
+
return with_retries(
|
|
2910
|
+
lambda: http_json(url, payload, self.credential.headers(), timeout=900),
|
|
2911
|
+
what,
|
|
2912
|
+
)
|
|
2913
|
+
|
|
2914
|
+
def _message_text(self, data: dict) -> str:
|
|
2915
|
+
choices = data.get("choices") or []
|
|
2916
|
+
if not choices:
|
|
2917
|
+
return ""
|
|
2918
|
+
message = choices[0].get("message") or {}
|
|
2919
|
+
return str(message.get("content") or "")
|
|
2920
|
+
|
|
2921
|
+
|
|
2588
2922
|
class XAIProvider(Provider):
|
|
2589
2923
|
"""xAI (Grok) images/generations.
|
|
2590
2924
|
|
|
@@ -2614,6 +2948,18 @@ class XAIProvider(Provider):
|
|
|
2614
2948
|
def default_model(self) -> str:
|
|
2615
2949
|
return "grok-2-image"
|
|
2616
2950
|
|
|
2951
|
+
@staticmethod
|
|
2952
|
+
def _named_for_its_bytes(out_path: Path) -> ImageResult:
|
|
2953
|
+
"""Imagine answers JPEG: keep the bytes (a PNG re-encode is ~4x larger)
|
|
2954
|
+
and give the file the extension they are, so it is served as what it is."""
|
|
2955
|
+
with out_path.open("rb") as handle:
|
|
2956
|
+
head = handle.read(12)
|
|
2957
|
+
if image_media_type(head) != "image/jpeg":
|
|
2958
|
+
return ImageResult(True, "png", out_path)
|
|
2959
|
+
jpg_path = out_path.with_suffix(".jpg")
|
|
2960
|
+
out_path.replace(jpg_path)
|
|
2961
|
+
return ImageResult(True, "jpg", jpg_path)
|
|
2962
|
+
|
|
2617
2963
|
def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
|
|
2618
2964
|
chain = xai_credential_chain(ctx.env)
|
|
2619
2965
|
if not chain:
|
|
@@ -2621,8 +2967,19 @@ class XAIProvider(Provider):
|
|
|
2621
2967
|
model = effective_model(settings, self)
|
|
2622
2968
|
out_path = out_base.with_suffix(".png")
|
|
2623
2969
|
payload = {"model": model, "prompt": prompt[:self.PROMPT_LIMIT], "n": 1}
|
|
2970
|
+
aspect = xai_aspect_ratio(model, settings.size)
|
|
2971
|
+
if aspect:
|
|
2972
|
+
payload["aspect_ratio"] = aspect
|
|
2624
2973
|
url = f"{xai_base_url(ctx.env)}/images/generations"
|
|
2625
2974
|
for index, credential in enumerate(chain):
|
|
2975
|
+
try:
|
|
2976
|
+
credential = xai_ready_credential(credential)
|
|
2977
|
+
except Exception as exc:
|
|
2978
|
+
fallback = chain[index + 1] if index + 1 < len(chain) else None
|
|
2979
|
+
if fallback is not None:
|
|
2980
|
+
warn(f"{exc} — retrying with {fallback.source}")
|
|
2981
|
+
continue
|
|
2982
|
+
return ImageResult(False, error=f"{exc}; re-run your Grok login")
|
|
2626
2983
|
debug(f"xAI generate: model={model} auth={credential.source}")
|
|
2627
2984
|
try:
|
|
2628
2985
|
data = with_retries(
|
|
@@ -2632,7 +2989,7 @@ class XAIProvider(Provider):
|
|
|
2632
2989
|
entries = data.get("data") or []
|
|
2633
2990
|
if not entries or not _write_image_payload(entries[0], out_path):
|
|
2634
2991
|
return ImageResult(False, error="No image data in xAI response")
|
|
2635
|
-
return
|
|
2992
|
+
return self._named_for_its_bytes(out_path)
|
|
2636
2993
|
except HttpStatusError as exc:
|
|
2637
2994
|
fallback = chain[index + 1] if index + 1 < len(chain) else None
|
|
2638
2995
|
if exc.status in self.AUTH_FAILURE_STATUSES and fallback is not None:
|
|
@@ -3027,19 +3384,22 @@ class Runner:
|
|
|
3027
3384
|
base_prompt = build_prompt(cf, file_settings)
|
|
3028
3385
|
prompt = base_prompt
|
|
3029
3386
|
orchestrated = settings.provider != "local" # local is deterministic
|
|
3030
|
-
if (orchestrated and file_settings.prompt_engine
|
|
3387
|
+
if (orchestrated and file_settings.prompt_engine in ("claude", "xai")
|
|
3031
3388
|
and not settings.dry_run):
|
|
3032
3389
|
prompt = claude_article_brief(
|
|
3033
|
-
self.ctx.
|
|
3390
|
+
self.ctx.orchestrator(file_settings.prompt_engine),
|
|
3391
|
+
cf, file_settings, base_prompt)
|
|
3034
3392
|
debug(f"Generated prompt: {prompt[:500]}...")
|
|
3035
3393
|
|
|
3036
3394
|
if settings.dry_run:
|
|
3037
3395
|
info("[DRY RUN] Would generate image:")
|
|
3038
3396
|
print(f" Provider: {settings.provider}")
|
|
3039
|
-
if orchestrated and file_settings.prompt_engine
|
|
3040
|
-
print(" Prompt engine:
|
|
3041
|
-
|
|
3042
|
-
|
|
3397
|
+
if orchestrated and file_settings.prompt_engine in ("claude", "xai"):
|
|
3398
|
+
print(f" Prompt engine: {file_settings.prompt_engine} "
|
|
3399
|
+
"(article analysis runs at generation time)")
|
|
3400
|
+
if orchestrated and file_settings.review_engine in ("claude", "xai"):
|
|
3401
|
+
print(f" Review: {file_settings.review_engine} "
|
|
3402
|
+
"(image review runs at generation time)")
|
|
3043
3403
|
print(f" Output: {out_base.with_suffix('.png')}")
|
|
3044
3404
|
print(f" Preview path: {preview_front_matter_path(file_settings, slug + '.png')}")
|
|
3045
3405
|
print(f" Prompt: {prompt[:400]}...")
|
|
@@ -3058,10 +3418,11 @@ class Runner:
|
|
|
3058
3418
|
result = provider.generate(prompt, file_settings, out_base, file_ctx)
|
|
3059
3419
|
|
|
3060
3420
|
# ---- Review: Claude inspects the render; at most ONE regeneration ----
|
|
3061
|
-
if (orchestrated and file_settings.review_engine
|
|
3062
|
-
and result.ok and result.path and result.kind
|
|
3421
|
+
if (orchestrated and file_settings.review_engine in ("claude", "xai")
|
|
3422
|
+
and result.ok and result.path and result.kind in ("png", "jpg")):
|
|
3063
3423
|
approved, critique, revised = claude_review_image(
|
|
3064
|
-
self.ctx.
|
|
3424
|
+
self.ctx.orchestrator(file_settings.review_engine),
|
|
3425
|
+
result.path, cf, prompt, file_settings)
|
|
3065
3426
|
if approved:
|
|
3066
3427
|
if critique:
|
|
3067
3428
|
debug(f"Claude review: {critique}")
|
|
@@ -3296,12 +3657,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3296
3657
|
help="Input fidelity (implies --enhance)")
|
|
3297
3658
|
parser.add_argument("--enhance-format", choices=["png", "jpeg", "webp"],
|
|
3298
3659
|
help="Enhanced output format (implies --enhance)")
|
|
3299
|
-
parser.add_argument("--prompt-engine", choices=["template", "claude"],
|
|
3300
|
-
help="Art-direction brief: claude
|
|
3301
|
-
"(default)
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3660
|
+
parser.add_argument("--prompt-engine", choices=["template", "claude", "xai"],
|
|
3661
|
+
help="Art-direction brief: claude or xai (Grok OAuth) "
|
|
3662
|
+
"analyzes the article (default: claude); template "
|
|
3663
|
+
"uses the built-in prompt")
|
|
3664
|
+
parser.add_argument("--review", choices=["claude", "xai", "none"],
|
|
3665
|
+
help="Post-render review: claude or xai inspects the image "
|
|
3666
|
+
"and may request one refined regeneration (default: claude)")
|
|
3305
3667
|
parser.add_argument("--rasterizer",
|
|
3306
3668
|
choices=["auto", "rsvg", "inkscape", "magick", "playwright", "none"],
|
|
3307
3669
|
help="SVG→PNG tool for claude/local providers (default: "
|
|
@@ -3391,6 +3753,35 @@ def resolve_auto_provider(settings: Settings, env: Dict[str, str]) -> Settings:
|
|
|
3391
3753
|
return replace(settings, provider="default")
|
|
3392
3754
|
|
|
3393
3755
|
|
|
3756
|
+
def _degrade_llm(settings: Settings, ctx: RunContext) -> Settings:
|
|
3757
|
+
"""Turn off Claude/Grok stages that have no credential, independently."""
|
|
3758
|
+
if settings.prompt_engine == "claude" or settings.review_engine == "claude":
|
|
3759
|
+
if ctx.claude().available():
|
|
3760
|
+
info(f"Claude orchestration: {ctx.claude().describe()}")
|
|
3761
|
+
else:
|
|
3762
|
+
warn(CLAUDE_CREDENTIAL_HINT)
|
|
3763
|
+
settings = replace(
|
|
3764
|
+
settings,
|
|
3765
|
+
prompt_engine=("template" if settings.prompt_engine == "claude"
|
|
3766
|
+
else settings.prompt_engine),
|
|
3767
|
+
review_engine=("none" if settings.review_engine == "claude"
|
|
3768
|
+
else settings.review_engine),
|
|
3769
|
+
)
|
|
3770
|
+
if settings.prompt_engine == "xai" or settings.review_engine == "xai":
|
|
3771
|
+
if ctx.grok_client().available():
|
|
3772
|
+
info(f"Grok orchestration: {ctx.grok_client().describe()}")
|
|
3773
|
+
else:
|
|
3774
|
+
warn(XAI_MISSING_HINT)
|
|
3775
|
+
settings = replace(
|
|
3776
|
+
settings,
|
|
3777
|
+
prompt_engine=("template" if settings.prompt_engine == "xai"
|
|
3778
|
+
else settings.prompt_engine),
|
|
3779
|
+
review_engine=("none" if settings.review_engine == "xai"
|
|
3780
|
+
else settings.review_engine),
|
|
3781
|
+
)
|
|
3782
|
+
return settings
|
|
3783
|
+
|
|
3784
|
+
|
|
3394
3785
|
def validate_credentials(settings: Settings, ctx: RunContext) -> None:
|
|
3395
3786
|
"""Credential checks are skipped for --list-missing/--dry-run (historical
|
|
3396
3787
|
behavior), but an unknown provider name (from AI_PROVIDER / _config.yml —
|
|
@@ -3450,19 +3841,12 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
3450
3841
|
settings = resolve_auto_provider(settings, ctx.env)
|
|
3451
3842
|
validate_credentials(settings, ctx)
|
|
3452
3843
|
|
|
3453
|
-
#
|
|
3454
|
-
#
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
)
|
|
3460
|
-
if wants_claude:
|
|
3461
|
-
if ctx.claude().available():
|
|
3462
|
-
info(f"Claude orchestration: {ctx.claude().describe()}")
|
|
3463
|
-
else:
|
|
3464
|
-
warn(CLAUDE_CREDENTIAL_HINT)
|
|
3465
|
-
settings = replace(settings, prompt_engine="template", review_engine="none")
|
|
3844
|
+
# LLM orchestration degrades gracefully: without a credential the run
|
|
3845
|
+
# continues on template prompts, unreviewed. Claude and Grok degrade
|
|
3846
|
+
# independently so a missing Claude token does not disable a Grok review.
|
|
3847
|
+
if (settings.provider not in ("local", "default")
|
|
3848
|
+
and not (settings.dry_run or settings.list_only)):
|
|
3849
|
+
settings = _degrade_llm(settings, ctx)
|
|
3466
3850
|
|
|
3467
3851
|
output_dir = project_root / settings.output_dir
|
|
3468
3852
|
if not settings.dry_run and not settings.list_only:
|