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.
@@ -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": "image/png",
2086
- "data": base64.b64encode(image_path.read_bytes()).decode("ascii"),
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
- files = [("image[]", image_path.name, image_path.read_bytes(), "image/png")]
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
- # Deliberately NOT implemented: minting or refreshing an OAuth token. xAI does
2342
- # not publish its device-code/PKCE endpoints or a public client id, and the
2343
- # credentials file belongs to whichever login tool wrote it — inventing a token
2344
- # endpoint, or writing back into another tool's store, would be guesswork. The
2345
- # engine CONSUMES a token that a Grok login already produced, treats an expired
2346
- # one as absent, and falls back to the API key.
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
- return [os.path.join(home, rel) for rel in XAI_DEFAULT_CREDENTIALS_FILES]
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
- debug(f"xAI OAuth: the token in {path} has expired; re-run your Grok login")
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
- if _xai_token_string(data.get("access_token")):
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 _xai_token_string(nested.get("access_token")):
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 ImageResult(True, "png", out_path)
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 == "claude"
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.claude(), cf, file_settings, base_prompt)
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 == "claude":
3040
- print(" Prompt engine: claude (article analysis runs at generation time)")
3041
- if orchestrated and file_settings.review_engine == "claude":
3042
- print(" Review: claude (image review runs at generation time)")
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 == "claude"
3062
- and result.ok and result.path and result.kind == "png"):
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.claude(), result.path, cf, prompt, file_settings)
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 analyzes the article "
3301
- "(default) or template uses the built-in prompt")
3302
- parser.add_argument("--review", choices=["claude", "none"],
3303
- help="Post-render review: claude inspects the image and "
3304
- "may request one refined regeneration (default: claude)")
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
- # Claude orchestration (analyze/review) degrades gracefully: without a
3454
- # Claude credential the run continues on template prompts, unreviewed.
3455
- wants_claude = (
3456
- settings.provider not in ("local", "default")
3457
- and "claude" in (settings.prompt_engine, settings.review_engine)
3458
- and not (settings.dry_run or settings.list_only)
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: