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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3ce0aa23d8bbc6d1dfe3572c2bd43f61441ad104b1d176538c2bd35b57546f1
|
|
4
|
+
data.tar.gz: db9e16c02e366d78b74cb98ac9697cc26a6c660f9aa7a349a93d678ab221a734
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
219
|
-
|
|
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
|
|
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 "
|
|
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" =>
|
|
169
|
-
"data" => Base64.strict_encode64(
|
|
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
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
(settings
|
|
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
|
|
36
|
-
["--review", [:review, "--review ENGINE", "claude
|
|
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
|