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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94ef9be8acf2f3c67c09f13a916886b5cb902677a949948833ee75bf11da456b
4
- data.tar.gz: 67da956c8c22361911764a8103a62a3075c615ea8ecde4145e1cf61a39152489
3
+ metadata.gz: b3ce0aa23d8bbc6d1dfe3572c2bd43f61441ad104b1d176538c2bd35b57546f1
4
+ data.tar.gz: db9e16c02e366d78b74cb98ac9697cc26a6c660f9aa7a349a93d678ab221a734
5
5
  SHA512:
6
- metadata.gz: 81cdf3446d4352ec701b51a85e7051df57ae891a6cd2f22803cb5a383350076b7d4afc52a676c181bc5eb5832cbe99b0fd557c84732b03de945050a888d92403
7
- data.tar.gz: a6687659e49d06b6b32cfb6c1e077ff625e014f88cef5e3be3d20c131720d59808370fc436d9f1d74d548b9679c05bf0fa4ffe3c3faaed22c49ca057be081460
6
+ metadata.gz: 88a563a299e10b07e9fce08ac2e781ff686e8c29d0650377e92d2f691441bf4294c8f374f49c3d2b576091a5ff02de325d56bfc8cba1cf6b8d3bf105d79c090b
7
+ data.tar.gz: 6c2121c4d2ce5215efa61c36ae546b25b26f93a4018859b6a3c57842c844f8ecbe62e6c72011351ddbb7c70b383a81bdaaf238b78865c0996268f3f024c83b22
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0](https://github.com/bamr87/zer0-image-generator/compare/zer0-image-generator/v0.8.0...zer0-image-generator/v0.9.0) (2026-10-07)
9
+
10
+
11
+ ### Features
12
+
13
+ * **providers:** Grok Imagine through a refreshable xAI login — Kilo token refresh, aspect ratio, Cloudflare-safe requests, honest .jpg (contains [#23](https://github.com/bamr87/zer0-image-generator/issues/23)) ([#24](https://github.com/bamr87/zer0-image-generator/issues/24)) ([affdc8e](https://github.com/bamr87/zer0-image-generator/commit/affdc8ecf25efb574e92f68e582e83cfa0f9c5c7))
14
+ * zer0 stack — design-token kit, engine Facade, and stack docs ([#22](https://github.com/bamr87/zer0-image-generator/issues/22)) ([9932668](https://github.com/bamr87/zer0-image-generator/commit/9932668b4f5666582c19ad30e33fed3833f5283b))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **web:** sync the zer0-ui-tokens kit's --maxw with application.css (1.0.1) ([#27](https://github.com/bamr87/zer0-image-generator/issues/27)) ([13ccd63](https://github.com/bamr87/zer0-image-generator/commit/13ccd633aea0a8f53c340f7d74a82c1bf277c43a))
20
+
8
21
  ## [0.8.0](https://github.com/bamr87/zer0-image-generator/compare/zer0-image-generator/v0.7.0...zer0-image-generator/v0.8.0) (2026-10-03)
9
22
 
10
23
 
data/README.md CHANGED
@@ -208,29 +208,35 @@ export ANTHROPIC_API_KEY="..." # 3. console.anthropic.com
208
208
 
209
209
  Keys are read from the environment or a git-ignored `.env` at the site root. On a Claude Pro/Max subscription the orchestration costs nothing extra; only the renderer bills per image.
210
210
 
211
- ### Grok OAuth (xAI renderer)
211
+ ### Grok OAuth (xAI renderer + art direction)
212
212
 
213
- `--provider xai` accepts a **Grok OAuth access token** as well as an API key, and tries them in that order — so a SuperGrok / X Premium+ login can render banners without a console.x.ai key:
213
+ The same **Grok OAuth access token** authenticates `--provider xai` *and* `--prompt-engine xai` / `--review xai` (Grok writes the art brief and vision-reviews the render). SuperGrok / X Premium+ can drive the whole pipeline without a console.x.ai key:
214
214
 
215
215
  ```bash
216
216
  export XAI_OAUTH_TOKEN="..." # 1. an OAuth access token (GROK_OAUTH_TOKEN also works)
217
217
  # …or a credentials file from a Grok login — ~/.grok/auth.json by default,
218
- # or set XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS to point elsewhere
219
- export XAI_API_KEY="..." # 2. console.x.ai — and the automatic fallback
218
+ # ~/.local/share/kilo/auth.json (Kilo's xAI store), or set
219
+ # XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS / KILO_AUTH_PATH
220
+ export XAI_API_KEY="..." # 2. console.x.ai — renderer fallback only
220
221
  ```
221
222
 
222
- Both are `Authorization: Bearer` credentials on the same endpoint, which is what makes the fallback real rather than cosmetic: **if the OAuth token is expired or the API rejects it (401/403) and an `XAI_API_KEY` is present, the same request is retried with the key** and the page still renders. A token whose recorded expiry has already passed is skipped without spending a request at all. Other failures (a 500, a bad prompt) are *not* credential problems, so they never burn the fallback.
223
+ Both are `Authorization: Bearer` credentials on the same endpoint, which is what makes the fallback real rather than cosmetic: **if the OAuth token is expired or the API rejects it (401/403) and an `XAI_API_KEY` is present, the same request is retried with the key** and the page still renders. A token whose recorded expiry has already passed, with nothing to refresh it with, is skipped without spending a request at all. Other failures (a 500, a bad prompt) are *not* credential problems, so they never burn the fallback.
223
224
 
224
- The engine only ever *reads* a token — it does not mint or refresh one, and never writes to the credentials file, because xAI publishes neither a device-code endpoint nor a public client id. Re-run your Grok login when a token expires.
225
+ **Expired tokens are refreshed when their store can.** The engine never mints a token (that takes an interactive login), but a login that saved a refresh token and a client id — Kilo Code's does, as `{ "xai": { "access", "refresh", "expires" } }` — is renewed at `https://auth.x.ai/oauth2/token` (the endpoint auth.x.ai's OIDC discovery document publishes) right before the first request that needs it. The rotated pair is written back into that same file, atomically, keeping its key names, time units, other entries and permissions, so the login tool and your next run both see it. The client id comes from the store, else from the access token's JWT `client_id` or `aud` claim. Parallel workers share one refresh. A refresh is never retried, because a refresh token that rotated on a lost response is already spent. If it fails, the chain moves on to `XAI_API_KEY`; with no key the page fails with the reason. A token with no refresh token still needs you to re-run your Grok login.
225
226
 
226
227
  | variable | effect |
227
228
  | --- | --- |
228
229
  | `XAI_OAUTH_TOKEN` / `GROK_OAUTH_TOKEN` | OAuth access token, tried first |
229
230
  | `XAI_OAUTH_CREDENTIALS` / `GROK_OAUTH_CREDENTIALS` | explicit path to a Grok credentials JSON (wins outright over `~/.grok/auth.json`) |
231
+ | `KILO_AUTH_PATH` | extra credentials file (Kilo store uses `{ "xai": { "access": "…", "refresh": "…" } }`; refreshed in place when expired) |
230
232
  | `XAI_API_KEY` | API key — the fallback rung |
231
233
  | `XAI_AUTH` | `auto` (default), `oauth` or `api_key` — pin one rung |
232
234
  | `XAI_BASE_URL` | override `https://api.x.ai/v1` |
233
235
 
236
+ **Imagine models get a banner shape.** With `--model grok-imagine-image-2.0` (any `grok-imagine*` model) the request carries the `aspect_ratio` nearest your `size` — `1536x1024` sends `3:2` — because Imagine renders a square by default and a wide banner would crop it. `grok-2-image` requests are unchanged. Imagine answers JPEG, so the banner is saved as `<slug>.jpg` (front matter points there) rather than JPEG bytes under a `.png` name; the vision review and `--enhance` label every upload with the MIME type its bytes declare.
237
+
238
+ Every request sends `User-Agent: zer0-image-generator` unless the caller names its own: `api.x.ai` and `auth.x.ai` sit behind Cloudflare, which answers a stock `Python-urllib` client with a 403 page or `error code: 1010`.
239
+
234
240
  The run's config banner names the credential it picked (`xai auth: XAI_OAUTH_TOKEN (Grok OAuth token), falling back to XAI_API_KEY (xAI API key)`), and a fallback mid-run is logged as a warning — a silent swap would hide a token that has quietly stopped working.
235
241
 
236
242
  ## CLI reference
@@ -326,6 +332,8 @@ The style catalog (`lib/zer0_image_generator/abc/art_styles.yml`) is the source
326
332
 
327
333
  The [zer0-mistakes](https://github.com/bamr87/zer0-mistakes) theme renders `preview:` values with a pure-Liquid include (GitHub-Pages-safe, no plugin needed) and currently vendors this engine at `scripts/lib/preview_generator.py`. This repo is the portable home: the theme is expected to consume the gem (or curl the engine from here) in a follow-up, so there is exactly ONE engine.
328
334
 
335
+ How the engine fits the zer0 stack — the theme, zer0-CMS, the `Facade` API and the shared design tokens — is on one page: [docs/ZER0-STACK.md](docs/ZER0-STACK.md).
336
+
329
337
  ## Development
330
338
 
331
339
  ```bash
@@ -26,8 +26,9 @@ require_relative "svg/local_renderer"
26
26
  require_relative "svg/rasterizer"
27
27
  require_relative "svg/lint"
28
28
  require_relative "svg/pixel_kit"
29
- require_relative "providers"
30
- require_relative "runner"
29
+ require_relative "providers"
30
+ require_relative "grok/client"
31
+ require_relative "runner"
31
32
  require_relative "cli"
32
33
 
33
34
  # ABC / alphabet-book plugin — additive and self-contained (touches none of the
@@ -156,6 +156,7 @@ module Zer0ImageGenerator
156
156
  end
157
157
  raise "no Anthropic credential configured" if @mode.nil?
158
158
 
159
+ image_bytes = File.binread(image_path.to_s)
159
160
  payload = {
160
161
  "model" => model,
161
162
  "max_tokens" => max_tokens,
@@ -165,8 +166,8 @@ module Zer0ImageGenerator
165
166
  "role" => "user",
166
167
  "content" => [
167
168
  { "type" => "image", "source" => {
168
- "type" => "base64", "media_type" => "image/png",
169
- "data" => Base64.strict_encode64(File.binread(image_path.to_s))
169
+ "type" => "base64", "media_type" => Zer0ImageGenerator.image_media_type(image_bytes),
170
+ "data" => Base64.strict_encode64(image_bytes)
170
171
  } },
171
172
  { "type" => "text", "text" => user_text }
172
173
  ]
@@ -26,8 +26,8 @@ module Zer0ImageGenerator
26
26
  ENHANCE_QUALITY_CHOICES = %w[low medium high auto].freeze
27
27
  ENHANCE_FIDELITY_CHOICES = %w[high low].freeze
28
28
  ENHANCE_FORMAT_CHOICES = %w[png jpeg webp].freeze
29
- PROMPT_ENGINE_CHOICES = %w[template claude].freeze
30
- REVIEW_CHOICES = %w[claude none].freeze
29
+ PROMPT_ENGINE_CHOICES = %w[template claude xai].freeze
30
+ REVIEW_CHOICES = %w[claude xai none].freeze
31
31
  RASTERIZER_CHOICES = %w[auto rsvg inkscape magick playwright none].freeze
32
32
 
33
33
  # The argparse Namespace, field-for-field. Booleans default false, parallel
@@ -200,6 +200,32 @@ module Zer0ImageGenerator
200
200
  end
201
201
  end
202
202
 
203
+ def degrade_llm(settings, ctx, deps:, logger:)
204
+ if settings.prompt_engine == "claude" || settings.review_engine == "claude"
205
+ if ctx.claude.available?
206
+ logger.info("Claude orchestration: #{ctx.claude.describe}")
207
+ else
208
+ logger.warn(deps.claude_credential_hint)
209
+ settings = settings.dup_with(
210
+ prompt_engine: (settings.prompt_engine == "claude" ? "template" : settings.prompt_engine),
211
+ review_engine: (settings.review_engine == "claude" ? "none" : settings.review_engine)
212
+ )
213
+ end
214
+ end
215
+ if settings.prompt_engine == "xai" || settings.review_engine == "xai"
216
+ if ctx.grok_client.available?
217
+ logger.info("Grok orchestration: #{ctx.grok_client.describe}")
218
+ else
219
+ logger.warn(XAIAuth::MISSING_HINT)
220
+ settings = settings.dup_with(
221
+ prompt_engine: (settings.prompt_engine == "xai" ? "template" : settings.prompt_engine),
222
+ review_engine: (settings.review_engine == "xai" ? "none" : settings.review_engine)
223
+ )
224
+ end
225
+ end
226
+ settings
227
+ end
228
+
203
229
  # Full run. Returns the process exit code (0/1). Usage errors and
204
230
  # error_exit escape as ExitError(status); CLI.start converts those for a
205
231
  # command-line binary.
@@ -239,19 +265,11 @@ module Zer0ImageGenerator
239
265
  ctx = deps.new_run_context(project_root, ENV.to_h)
240
266
  validate_credentials(settings, ctx, deps: deps, logger: logger)
241
267
 
242
- # Claude orchestration (analyze/review) degrades gracefully: without a
243
- # Claude credential the run continues on template prompts, unreviewed.
244
- wants_claude =
245
- settings.provider != "local" &&
246
- (settings.prompt_engine == "claude" || settings.review_engine == "claude") &&
247
- !(settings.dry_run || settings.list_only)
248
- if wants_claude
249
- if ctx.claude.available?
250
- logger.info("Claude orchestration: #{ctx.claude.describe}")
251
- else
252
- logger.warn(deps.claude_credential_hint)
253
- settings = deps.degrade_settings(settings)
254
- end
268
+ # LLM orchestration degrades gracefully: without a credential the run
269
+ # continues on template prompts, unreviewed. Claude and Grok degrade
270
+ # independently so a missing Claude token does not disable a Grok review.
271
+ unless settings.dry_run || settings.list_only || settings.provider == "local"
272
+ settings = degrade_llm(settings, ctx, deps: deps, logger: logger)
255
273
  end
256
274
 
257
275
  output_dir = Pathname.new(project_root.to_s) / settings.output_dir
@@ -32,8 +32,8 @@ module Zer0ImageGenerator
32
32
  ["--enhance-quality", [:enhance_quality, "--enhance-quality Q", "low|medium|high|auto"]],
33
33
  ["--enhance-fidelity", [:enhance_fidelity, "--enhance-fidelity F", "high|low (implies --enhance)"]],
34
34
  ["--enhance-format", [:enhance_format, "--enhance-format FMT", "png|jpeg|webp (implies --enhance)"]],
35
- ["--prompt-engine", [:prompt_engine, "--prompt-engine ENGINE", "claude (analyzes the article) or template"]],
36
- ["--review", [:review, "--review ENGINE", "claude (vision-reviews the render) or none"]],
35
+ ["--prompt-engine", [:prompt_engine, "--prompt-engine ENGINE", "claude | xai | template"]],
36
+ ["--review", [:review, "--review ENGINE", "claude | xai | none"]],
37
37
  ["--rasterizer", [:rasterizer, "--rasterizer TOOL", "auto|rsvg|inkscape|magick|playwright|none"]],
38
38
  ["--style", [:style, "--style TEXT", "Override the image style prompt"]],
39
39
  ["--assets-prefix", [:assets_prefix, "--assets-prefix PREFIX", "Assets prefix for path normalization"]],
@@ -158,6 +158,17 @@ module Zer0ImageGenerator
158
158
  PROMPT
159
159
 
160
160
  PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
161
+ JPEG_SIGNATURE = "\xff\xd8\xff".b.freeze
162
+
163
+ # The MIME type a raster's own bytes declare. Providers do not all answer PNG
164
+ # (xAI Imagine answers JPEG), and vision APIs check the label.
165
+ def self.image_media_type(data)
166
+ bytes = data.to_s.b
167
+ return "image/jpeg" if bytes[0, 3] == JPEG_SIGNATURE
168
+ return "image/webp" if bytes[0, 4] == "RIFF" && bytes[8, 4] == "WEBP"
169
+
170
+ "image/png"
171
+ end
161
172
 
162
173
  # Pause after each successful non-dry generation (Bash parity: polite pacing
163
174
  # between paid API calls).
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zer0ImageGenerator::Facade — the stable, Rails-free API for other apps.
4
+ #
5
+ # zer0-CMS (and any other host) asks the image engine two questions:
6
+ #
7
+ # Zer0ImageGenerator::Facade.missing_previews(site_root)
8
+ # # => [MissingPreview(path:, relative:, collection:, title:, preview:), ...]
9
+ #
10
+ # Zer0ImageGenerator::Facade.generate_one(site_root, "_posts/2026-01-01-x.md",
11
+ # provider: "local", env: {}, dry_run: false)
12
+ # # => Generation(status:, file:, provider:, preview:, image:, stats:,
13
+ # # exit_code:, error:, log:)
14
+ #
15
+ # Both are compositions of the engine's own objects — read_config,
16
+ # apply_source_root, resolve_settings, Runner#collection_path, Runner#discover,
17
+ # Content.parse_front_matter / check_preview_exists, CLI.validate_credentials,
18
+ # Runner#run — so "missing" and "generate" mean exactly what they mean to
19
+ # `jekyll preview-images --list-missing` and `jekyll preview-images -f FILE`.
20
+ # No pipeline logic lives here; the only things this file adds are path
21
+ # confinement (every path must resolve inside the site root) and structured
22
+ # results in place of printed lines.
23
+ #
24
+ # Contract (semver-stable from the release that ships this file):
25
+ # - A caller error (site root not a directory, file not found, a path, `source:`
26
+ # or `collections_dir:`/`output_dir:` that resolves outside the site root)
27
+ # raises Facade::Error, a Zer0ImageGenerator::Error.
28
+ # - An engine outcome (unknown provider, missing credential, a failed render)
29
+ # is returned as a Generation with status :error — never raised.
30
+ # - `env:` is the credential environment handed to the providers (the same
31
+ # RunContext env the web app uses); the process ENV is never mutated.
32
+ # Settings precedence is the CLI's own — `preview_images:` config, overridden
33
+ # by the documented env vars (AI_PROVIDER, OUTPUT_DIR, ...) of the process.
34
+ # - Thread-safe per call: each call builds its own Runner, logger and
35
+ # RunContext. Slice-level messages (front-matter writes, rasterizer notes)
36
+ # still go to Zer0ImageGenerator::Logging.logger, which this file never
37
+ # swaps, so concurrent callers cannot steal each other's output.
38
+ #
39
+ # Stdlib only, by policy.
40
+
41
+ require "pathname"
42
+ require "fileutils"
43
+
44
+ require_relative "all"
45
+
46
+ module Zer0ImageGenerator
47
+ module Facade
48
+ class Error < Zer0ImageGenerator::Error; end
49
+
50
+ # One content file whose preview is absent or points at nothing on disk.
51
+ # `path` is absolute; `relative` is relative to the site root (so it
52
+ # includes a `source:` directory); `preview` is the stale front-matter
53
+ # value, or nil when the key is absent.
54
+ MissingPreview = Struct.new(:path, :relative, :collection, :title, :preview,
55
+ keyword_init: true)
56
+
57
+ # The outcome of generate_one.
58
+ # status :generated | :would_generate (dry run) | :skipped | :error
59
+ # file absolute path of the content file
60
+ # provider the provider the run resolved to
61
+ # preview the front-matter value after the run (nil when absent)
62
+ # image absolute path of that preview on disk (nil when not found)
63
+ # stats { processed:, generated:, enhanced:, skipped:, errors: }
64
+ # exit_code the engine's exit status (0 ok, 1 failure)
65
+ # error a human-readable reason when status is :error
66
+ # log [[level, text], ...] — the run's own log, ANSI stripped
67
+ Generation = Struct.new(:status, :file, :provider, :preview, :image, :stats,
68
+ :exit_code, :error, :log, keyword_init: true) do
69
+ def ok?
70
+ status != :error
71
+ end
72
+ end
73
+
74
+ # Logger sink that records instead of printing (see logging.rb's sink
75
+ # contract: log / say / header).
76
+ class Recorder
77
+ ANSI = /\e\[[0-9;]*m/.freeze
78
+
79
+ def initialize
80
+ @entries = []
81
+ @lock = Mutex.new
82
+ end
83
+
84
+ def entries
85
+ @lock.synchronize { @entries.dup }
86
+ end
87
+
88
+ def log(message, level)
89
+ push(level, message)
90
+ end
91
+
92
+ def say(text)
93
+ push(:say, text)
94
+ end
95
+
96
+ def header(title)
97
+ push(:header, title)
98
+ end
99
+
100
+ private
101
+
102
+ def push(level, text)
103
+ @lock.synchronize { @entries << [level.to_sym, text.to_s.gsub(ANSI, "")] }
104
+ end
105
+ end
106
+
107
+ # Every content file in the site's configured collections that lacks a
108
+ # valid preview, in the order a run would visit them.
109
+ def self.missing_previews(site_root)
110
+ site = site_root!(site_root)
111
+ config = Zer0ImageGenerator.read_config(site)
112
+ root = project_root!(site, config)
113
+ settings = Zer0ImageGenerator.resolve_settings(Args.build({}), config)
114
+ runner = Runner.new(settings, root,
115
+ ctx: RunContext.new(project_root: root, env: {}),
116
+ logger: Logger.new(sink: Recorder.new))
117
+
118
+ settings.collections.uniq.flat_map do |name|
119
+ dir = runner.collection_path(name)
120
+ next [] unless dir.directory?
121
+ unless inside?(dir, site)
122
+ raise Error, "collection '#{name}' resolves outside the site root: #{dir}"
123
+ end
124
+
125
+ runner.discover(dir).filter_map do |path|
126
+ next unless inside?(path, site) # a symlink out of the site is not ours
127
+
128
+ cf = Content.parse_front_matter(path, settings.front_matter_key)
129
+ next if cf.nil?
130
+ next if cf.preview && Content.check_preview_exists(cf.preview, settings, root)
131
+
132
+ MissingPreview.new(path: path.to_s, relative: path.relative_path_from(site).to_s,
133
+ collection: name, title: cf.title, preview: cf.preview)
134
+ end
135
+ end
136
+ end
137
+
138
+ # Generate (or, with dry_run, plan) the preview for ONE content file.
139
+ # `file` is absolute, or relative to the content root (`source:`), or
140
+ # relative to the site root — tried in that order, as the CLI does.
141
+ # `provider: nil` uses the site's configured provider.
142
+ def self.generate_one(site_root, file, provider: "local", env: {}, dry_run: false)
143
+ site = site_root!(site_root)
144
+ config = Zer0ImageGenerator.read_config(site)
145
+ root = project_root!(site, config)
146
+ target = content_file!(site, root, file)
147
+
148
+ args = Args.build(file: target.to_s, provider: provider, dry_run: dry_run ? true : false)
149
+ settings = Zer0ImageGenerator.resolve_settings(args, config)
150
+ unless inside?(root / settings.output_dir, site)
151
+ raise Error, "output_dir resolves outside the site root: #{settings.output_dir}"
152
+ end
153
+
154
+ recorder = Recorder.new
155
+ logger = Logger.new(sink: recorder)
156
+ ctx = RunContext.new(project_root: root, env: string_env(env))
157
+ runner = Runner.new(settings, root, ctx: ctx, logger: logger)
158
+ error = nil
159
+ begin
160
+ CLI.validate_credentials(settings, ctx, logger: logger)
161
+ FileUtils.mkdir_p((root / settings.output_dir).to_s) unless settings.dry_run
162
+ exit_code = runner.run
163
+ rescue ExitError => e
164
+ error = e.message
165
+ exit_code = e.status
166
+ end
167
+
168
+ stats = Stats::ATTRS.to_h { |name| [name, runner.stats.public_send(name)] }
169
+ log = recorder.entries
170
+ status =
171
+ if error || stats[:errors].positive? then :error
172
+ elsif stats[:generated].positive? then settings.dry_run ? :would_generate : :generated
173
+ else :skipped
174
+ end
175
+ if status == :error && error.nil?
176
+ error = log.select { |level, _| level == :warning }.map { |_, text| text.strip }.join("\n")
177
+ end
178
+
179
+ cf = Content.parse_front_matter(target, settings.front_matter_key)
180
+ preview = cf&.preview
181
+ image = preview && Content.find_preview_image(preview, settings, root)
182
+
183
+ Generation.new(status: status, file: target.to_s, provider: settings.provider,
184
+ preview: preview, image: image && File.expand_path(image.to_s),
185
+ stats: stats, exit_code: exit_code, error: error, log: log)
186
+ end
187
+
188
+ # --- confinement -----------------------------------------------------------
189
+
190
+ def self.site_root!(site_root)
191
+ raise Error, "site root is required" if site_root.nil? || site_root.to_s.strip.empty?
192
+
193
+ path = Pathname.new(File.expand_path(site_root.to_s))
194
+ raise Error, "site root is not a directory: #{site_root}" unless path.directory?
195
+
196
+ path
197
+ end
198
+
199
+ # Jekyll's `source:` shifts the content root; it may not leave the site.
200
+ def self.project_root!(site, config)
201
+ root = Zer0ImageGenerator.apply_source_root(site, config)
202
+ raise Error, "source: resolves outside the site root: #{config['source']}" unless inside?(root, site)
203
+
204
+ root
205
+ end
206
+
207
+ def self.content_file!(site, root, file)
208
+ raise Error, "file is required" if file.nil? || file.to_s.strip.empty?
209
+
210
+ raw = Pathname.new(file.to_s)
211
+ candidates = raw.absolute? ? [raw] : [root / raw, site / raw]
212
+ target = candidates.find(&:file?)
213
+ raise Error, "content file not found: #{file}" if target.nil?
214
+
215
+ target = Pathname.new(File.expand_path(target.to_s))
216
+ raise Error, "content file is outside the site root: #{file}" unless inside?(target, site)
217
+
218
+ target
219
+ end
220
+
221
+ # True when `path` — symlinks resolved, even for a not-yet-created tail —
222
+ # is `root` or lies beneath it.
223
+ def self.inside?(path, root)
224
+ resolved = canonical(path).to_s
225
+ base = canonical(root).to_s
226
+ resolved == base || resolved.start_with?(base.end_with?("/") ? base : "#{base}/")
227
+ end
228
+
229
+ def self.canonical(path)
230
+ probe = Pathname.new(File.expand_path(path.to_s))
231
+ tail = []
232
+ until probe.exist? || probe.root?
233
+ tail.unshift(probe.basename.to_s)
234
+ probe = probe.dirname
235
+ end
236
+ tail.inject(probe.realpath) { |acc, part| acc + part }
237
+ end
238
+
239
+ def self.string_env(env)
240
+ (env || {}).to_h.each_with_object({}) do |(key, value), acc|
241
+ acc[key.to_s] = value.to_s unless value.nil?
242
+ end
243
+ end
244
+
245
+ private_class_method :site_root!, :project_root!, :content_file!, :inside?,
246
+ :canonical, :string_env
247
+ end
248
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ require_relative "../constants"
6
+ require_relative "../http"
7
+ require_relative "../providers/xai_auth"
8
+
9
+ module Zer0ImageGenerator
10
+ # OpenAI-compatible chat client for api.x.ai. Duck-typed against
11
+ # AnthropicClient (`complete`, `complete_vision`, `available?`, `describe`)
12
+ # so the analyze/review stages can run on Grok with the same OAuth token
13
+ # the image renderer already uses.
14
+ class GrokClient
15
+ DEFAULT_MODEL = "grok-4"
16
+
17
+ def initialize(env = nil, transport: nil)
18
+ @env = env.nil? ? ENV.to_h : env.to_h
19
+ @transport = transport || Http
20
+ @credential = XAIAuth.chain(@env).first
21
+ end
22
+
23
+ def available?
24
+ !@credential.nil?
25
+ end
26
+
27
+ def describe
28
+ @credential ? @credential.source : "none"
29
+ end
30
+
31
+ def complete(system_text, user_text, model: DEFAULT_MODEL,
32
+ max_tokens: 2048, effort: "")
33
+ raise "no xAI credential configured" unless @credential
34
+
35
+ payload = {
36
+ "model" => resolve_model(model),
37
+ "messages" => [
38
+ { "role" => "system", "content" => system_text },
39
+ { "role" => "user", "content" => user_text }
40
+ ],
41
+ "max_tokens" => max_tokens
42
+ }
43
+ message_text(api_call(payload, "xAI chat"))
44
+ end
45
+
46
+ def complete_vision(system_text, user_text, image_path, model: DEFAULT_MODEL,
47
+ max_tokens: 2048, effort: "")
48
+ raise "no xAI credential configured" unless @credential
49
+
50
+ raw = File.binread(image_path.to_s)
51
+ b64 = Base64.strict_encode64(raw)
52
+ payload = {
53
+ "model" => resolve_model(model),
54
+ "messages" => [
55
+ { "role" => "system", "content" => system_text },
56
+ { "role" => "user", "content" => [
57
+ { "type" => "image_url",
58
+ "image_url" => { "url" => "data:#{Zer0ImageGenerator.image_media_type(raw)};base64,#{b64}" } },
59
+ { "type" => "text", "text" => user_text }
60
+ ] }
61
+ ],
62
+ "max_tokens" => max_tokens
63
+ }
64
+ message_text(api_call(payload, "xAI chat (review)"))
65
+ end
66
+
67
+ private
68
+
69
+ def resolve_model(model)
70
+ name = model.to_s.strip
71
+ return DEFAULT_MODEL if name.empty? || name.start_with?("claude")
72
+
73
+ name
74
+ end
75
+
76
+ def api_call(payload, what)
77
+ @credential = XAIAuth.ready_credential(@credential)
78
+ url = "#{XAIAuth.base_url(@env)}/chat/completions"
79
+ Http.with_retries(what) do
80
+ @transport.json(url, payload, @credential.headers, timeout: 900)
81
+ end
82
+ end
83
+
84
+ def message_text(data)
85
+ choices = data["choices"] || []
86
+ return "" if choices.empty?
87
+
88
+ message = choices[0]["message"] || {}
89
+ message["content"].to_s
90
+ end
91
+ end
92
+ end
@@ -138,7 +138,13 @@ module Zer0ImageGenerator
138
138
  MAX_REDIRECTS = 10
139
139
  REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
140
140
 
141
+ # Sent on every request that does not name its own client. api.x.ai and
142
+ # auth.x.ai sit behind Cloudflare, whose bot rules answer a stock client
143
+ # signature with a 403 page or `error code: 1010`.
144
+ USER_AGENT = "zer0-image-generator"
145
+
141
146
  def request(url, method: "GET", headers: nil, data: nil, timeout: 120)
147
+ headers = with_user_agent(headers)
142
148
  uri = URI(url)
143
149
  redirects = 0
144
150
  loop do
@@ -243,6 +249,14 @@ module Zer0ImageGenerator
243
249
  end
244
250
  private_class_method :perform
245
251
 
252
+ def with_user_agent(headers)
253
+ headers = (headers || {}).to_h
254
+ return headers if headers.keys.any? { |key| key.to_s.casecmp?("user-agent") }
255
+
256
+ headers.merge("User-Agent" => USER_AGENT)
257
+ end
258
+ private_class_method :with_user_agent
259
+
246
260
  def request_class(method)
247
261
  case method.to_s.upcase
248
262
  when "GET" then Net::HTTP::Get