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.
@@ -120,12 +120,13 @@ module Zer0ImageGenerator
120
120
  # cross-contaminate concurrent renders.
121
121
  class RunContext
122
122
  attr_reader :project_root, :env
123
- attr_accessor :anthropic, :slug, :article
123
+ attr_accessor :anthropic, :grok, :slug, :article
124
124
 
125
- def initialize(project_root:, env:, anthropic: nil, slug: "", article: nil)
125
+ def initialize(project_root:, env:, anthropic: nil, grok: nil, slug: "", article: nil)
126
126
  @project_root = project_root
127
127
  @env = env
128
128
  @anthropic = anthropic
129
+ @grok = grok
129
130
  @slug = slug
130
131
  # The page's own context (slug/section/title/categories/description/body),
131
132
  # set per file by the runner. The local renderer derives BOTH composition
@@ -139,6 +140,14 @@ module Zer0ImageGenerator
139
140
  @anthropic ||= AnthropicClient.new(@env)
140
141
  end
141
142
 
143
+ def grok_client
144
+ @grok ||= GrokClient.new(@env)
145
+ end
146
+
147
+ def orchestrator(engine)
148
+ engine.to_s == "xai" ? grok_client : claude
149
+ end
150
+
142
151
  # Per-file copy carrying a fresh slug (the runner calls `ctx.with_slug(slug)`
143
152
  # once per worker). project_root/env/anthropic are shared by reference on
144
153
  # purpose: env is read-only and the AnthropicClient is stateless after init,
@@ -146,7 +155,7 @@ module Zer0ImageGenerator
146
155
  # and therefore must not be shared.
147
156
  def with_slug(slug, article: nil)
148
157
  RunContext.new(project_root: @project_root, env: @env,
149
- anthropic: @anthropic, slug: slug, article: article)
158
+ anthropic: @anthropic, grok: @grok, slug: slug, article: article)
150
159
  end
151
160
  end
152
161
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../constants"
3
4
  require_relative "base"
4
5
 
5
6
  module Zer0ImageGenerator
@@ -61,8 +62,9 @@ module Zer0ImageGenerator
61
62
  }
62
63
  # gpt-image-2 does not accept input_fidelity (historical behavior).
63
64
  fields["input_fidelity"] = settings.enhance_fidelity if model != "gpt-image-2"
65
+ image_bytes = File.binread(image_path.to_s)
64
66
  files = [["image[]", image_path.basename.to_s,
65
- File.binread(image_path.to_s), "image/png"]]
67
+ image_bytes, Zer0ImageGenerator.image_media_type(image_bytes)]]
66
68
  out_path ||= image_path
67
69
  Logging.debug(
68
70
  "OpenAI edit: model=#{model} fidelity=" \
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../constants"
3
4
  require_relative "base"
4
5
  require_relative "xai_auth"
5
6
 
@@ -39,6 +40,18 @@ module Zer0ImageGenerator
39
40
  "grok-2-image"
40
41
  end
41
42
 
43
+ # Imagine answers JPEG: keep the bytes (a PNG re-encode is ~4x larger) and
44
+ # give the file the extension they are, so it is served as what it is.
45
+ def named_for_its_bytes(out_path)
46
+ head = File.open(out_path.to_s, "rb") { |handle| handle.read(12) }
47
+ return ImageResult.new(true, "png", out_path) unless
48
+ Zer0ImageGenerator.image_media_type(head) == "image/jpeg"
49
+
50
+ jpg_path = out_path.sub_ext(".jpg")
51
+ File.rename(out_path.to_s, jpg_path.to_s)
52
+ ImageResult.new(true, "jpg", jpg_path)
53
+ end
54
+
42
55
  def generate(prompt, settings, out_base, ctx)
43
56
  credentials = XAIAuth.chain(ctx.env)
44
57
  return ImageResult.new(false, error: XAIAuth::MISSING_HINT) if credentials.empty?
@@ -46,9 +59,22 @@ module Zer0ImageGenerator
46
59
  model = Zer0ImageGenerator.effective_model(settings, self)
47
60
  out_path = out_base.sub_ext(".png")
48
61
  payload = { "model" => model, "prompt" => prompt[0, PROMPT_LIMIT], "n" => 1 }
62
+ aspect = XAIAuth.aspect_ratio(model, settings.size)
63
+ payload["aspect_ratio"] = aspect if aspect
49
64
  url = "#{XAIAuth.base_url(ctx.env)}/images/generations"
50
65
 
51
66
  credentials.each_with_index do |credential, index|
67
+ begin
68
+ credential = XAIAuth.ready_credential(credential)
69
+ rescue StandardError => exc
70
+ fallback = credentials[index + 1]
71
+ if fallback
72
+ Logging.warn("#{exc.message} — retrying with #{fallback.source}")
73
+ next
74
+ end
75
+
76
+ return ImageResult.new(false, error: "#{exc.message}; re-run your Grok login")
77
+ end
52
78
  Logging.debug("xAI generate: model=#{model} auth=#{credential.source}")
53
79
  begin
54
80
  data = Http.with_retries("xAI API") do
@@ -59,7 +85,7 @@ module Zer0ImageGenerator
59
85
  return ImageResult.new(false, error: "No image data in xAI response")
60
86
  end
61
87
 
62
- return ImageResult.new(true, "png", out_path)
88
+ return named_for_its_bytes(out_path)
63
89
  rescue HttpStatusError => exc
64
90
  fallback = credentials[index + 1]
65
91
  if AUTH_FAILURE_STATUSES.include?(exc.status) && fallback
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require "time"
5
+ require "uri"
6
+
7
+ require_relative "../http"
5
8
 
6
9
  # xAI (Grok) credential resolution, shared by the XAIProvider.
7
10
  #
@@ -10,23 +13,28 @@ require "time"
10
13
  # console API key is where the value comes from and what we tell the human when
11
14
  # it is rejected. This file owns that chain; the provider owns the request.
12
15
  #
13
- # Deliberately NOT implemented here: minting or refreshing an OAuth token. xAI
14
- # does not publish its device-code/PKCE endpoints or a public client id, and the
15
- # credentials file belongs to whichever login tool wrote it — inventing a token
16
- # endpoint, or writing back into another tool's store, would be guesswork. The
17
- # engine CONSUMES a token that a Grok login already produced, treats an expired
18
- # one as absent, and falls back to the API key.
16
+ # Refresh, not login. The engine never MINTS a token — that takes an interactive
17
+ # login — but an expired token whose store also holds a refresh
18
+ # token and a client id (Kilo Code's login does) is refreshed at auth.x.ai once,
19
+ # right before a request needs it, and written back to that store so the login
20
+ # tool and the next run both see the rotated pair (lifehacker.dev's cover-art
21
+ # script already refreshes and saves the same way). Expired with nothing to
22
+ # refresh with, a token is treated as absent and the chain falls back to the
23
+ # API key.
19
24
  module Zer0ImageGenerator
20
25
  # One resolved way to authenticate against api.x.ai.
21
26
  class XAICredential
22
- attr_reader :mode, :token, :source
27
+ attr_reader :mode, :token, :source, :refresh_path
23
28
 
24
29
  # mode: "oauth" | "api_key" — which rung of the chain this is.
25
30
  # source: human label for logs and fallback warnings.
26
- def initialize(mode, token, source)
31
+ # refresh_path: set when the token has expired but its store can refresh
32
+ # it — the token is empty until XAIAuth.ready_credential refreshes it.
33
+ def initialize(mode, token, source, refresh_path = nil)
27
34
  @mode = mode
28
35
  @token = token
29
36
  @source = source
37
+ @refresh_path = refresh_path
30
38
  end
31
39
 
32
40
  def headers
@@ -50,7 +58,8 @@ module Zer0ImageGenerator
50
58
  # Where a Grok login drops its token when no path is configured. Relative to
51
59
  # the home directory so a test (and a container) can point HOME elsewhere.
52
60
  DEFAULT_CREDENTIALS_FILES = [
53
- ".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json"
61
+ ".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json",
62
+ ".local/share/kilo/auth.json"
54
63
  ].freeze
55
64
 
56
65
  # Credential files nest the token differently per tool; look at the top
@@ -67,6 +76,17 @@ module Zer0ImageGenerator
67
76
  AUTH_MODE_ENV = "XAI_AUTH"
68
77
  AUTH_MODES = %w[auto oauth api_key].freeze
69
78
 
79
+ # The refresh grant's endpoint, as auth.x.ai's OIDC discovery document
80
+ # (/.well-known/openid-configuration) publishes it.
81
+ TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
82
+ REFRESH_TOKEN_KEYS = %w[refresh_token refresh].freeze
83
+ EXPIRY_KEYS = %w[expires_at expiry expiration expires].freeze
84
+ ISSUED_KEYS = %w[created_at obtained_at issued_at issued].freeze
85
+ # Workers share one store: the first to find the token expired refreshes
86
+ # it, the rest wait and read the result. A refresh token may rotate on use,
87
+ # so two concurrent refreshes would leave one worker holding a dead token.
88
+ REFRESH_LOCK = Mutex.new
89
+
70
90
  MISSING_HINT =
71
91
  "The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n" \
72
92
  " 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n" \
@@ -148,7 +168,10 @@ module Zer0ImageGenerator
148
168
  home = home_dir(env)
149
169
  return [] if home.nil? || home.empty?
150
170
 
151
- DEFAULT_CREDENTIALS_FILES.map { |rel| File.join(home, rel) }
171
+ paths = DEFAULT_CREDENTIALS_FILES.map { |rel| File.join(home, rel) }
172
+ kilo = env["KILO_AUTH_PATH"]
173
+ paths.unshift(kilo) unless kilo.nil? || kilo.empty?
174
+ paths
152
175
  end
153
176
 
154
177
  # The env snapshot is the source of truth (tests and containers set HOME);
@@ -182,7 +205,13 @@ module Zer0ImageGenerator
182
205
  return nil
183
206
  end
184
207
  if expired?(node)
185
- debug("xAI OAuth: the token in #{path} has expired; re-run your Grok login")
208
+ if refresh_token(node) && client_id(node)
209
+ return XAICredential.new("oauth", "",
210
+ "Grok OAuth token (#{path}; expired, refreshed on use)",
211
+ path)
212
+ end
213
+ debug("xAI OAuth: the token in #{path} has expired and holds nothing to " \
214
+ "refresh it with; re-run your Grok login")
186
215
  return nil
187
216
  end
188
217
 
@@ -196,14 +225,182 @@ module Zer0ImageGenerator
196
225
  nil
197
226
  end
198
227
 
228
+ # The credential ready to send: itself, or for an expired OAuth token its
229
+ # store can refresh, the refreshed one. Raises RuntimeError on a failed refresh.
230
+ def ready_credential(credential)
231
+ return credential if credential.refresh_path.nil?
232
+
233
+ refresh_file_credential(credential.refresh_path)
234
+ end
235
+
236
+ def refresh_file_credential(path)
237
+ REFRESH_LOCK.synchronize do
238
+ begin
239
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
240
+ rescue JSON::ParserError, SystemCallError, IOError => exc
241
+ raise "xAI OAuth: cannot re-read #{path} (#{exc.message})"
242
+ end
243
+ node = data.is_a?(Hash) ? find_token_node(data) : nil
244
+ raise "xAI OAuth: no access token left in #{path}" if node.nil?
245
+
246
+ # Another worker may have refreshed the store while this one waited.
247
+ current = token_node(data)
248
+ if current && !expired?(current)
249
+ return XAICredential.new("oauth", current["access_token"].to_s,
250
+ "Grok OAuth token (#{path})")
251
+ end
252
+ refresh = refresh_token(node)
253
+ cid = client_id(node)
254
+ raise "xAI OAuth: the token in #{path} cannot be refreshed" if refresh.nil? || cid.nil?
255
+
256
+ fresh = request_refresh(refresh, cid)
257
+ store_refreshed(node, fresh, Time.now.to_f)
258
+ begin
259
+ write_store(path, data)
260
+ rescue SystemCallError, IOError => exc
261
+ # The refresh token may already have rotated, so say so loudly — but
262
+ # this run still holds a working access token.
263
+ warn_log("xAI OAuth: refreshed, but could not save the new token to #{path} " \
264
+ "(#{exc.message}); the login tool may need a fresh sign-in")
265
+ end
266
+ debug("xAI OAuth: refreshed the token in #{path}")
267
+ XAICredential.new("oauth", fresh["access_token"].to_s,
268
+ "Grok OAuth token (#{path}; refreshed)")
269
+ end
270
+ end
271
+
272
+ # POST the refresh grant. Never retried: a refresh token that rotated on a
273
+ # request whose response was lost is already spent.
274
+ def request_refresh(refresh_token, client_id)
275
+ body = URI.encode_www_form("grant_type" => "refresh_token",
276
+ "refresh_token" => refresh_token,
277
+ "client_id" => client_id)
278
+ headers = { "Content-Type" => "application/x-www-form-urlencoded",
279
+ "Accept" => "application/json" }
280
+ begin
281
+ _, _, raw = Http.request(TOKEN_ENDPOINT, method: "POST", headers: headers,
282
+ data: body, timeout: 60)
283
+ rescue HttpStatusError => exc
284
+ # The OAuth error code (invalid_grant, invalid_client) is safe to show
285
+ # and says whether a re-login is due; the description may echo input.
286
+ details = exc.json
287
+ code = details.is_a?(Hash) ? details["error"] : nil
288
+ reason = token_string?(code) ? "HTTP #{exc.status}: #{code}" : "HTTP #{exc.status}"
289
+ raise "xAI OAuth refresh failed (#{reason})"
290
+ end
291
+ begin
292
+ data = JSON.parse(raw.to_s.dup.force_encoding("UTF-8"))
293
+ rescue JSON::ParserError, EncodingError
294
+ raise "xAI OAuth refresh returned non-JSON"
295
+ end
296
+ unless data.is_a?(Hash) && token_string?(data["access_token"])
297
+ raise "xAI OAuth refresh returned no access_token"
298
+ end
299
+
300
+ data
301
+ end
302
+
303
+ # Write the refreshed pair into the store's own node, in its own key names
304
+ # and time units, so the tool that owns the file still reads it.
305
+ def store_refreshed(node, fresh, now)
306
+ token_key = token_string?(node["access_token"]) ? "access_token" : "access"
307
+ node[token_key] = fresh["access_token"]
308
+ if token_string?(fresh["refresh_token"])
309
+ refresh_key = REFRESH_TOKEN_KEYS.find { |key| token_string?(node[key]) } || "refresh_token"
310
+ node[refresh_key] = fresh["refresh_token"]
311
+ end
312
+ lifetime = number(fresh["expires_in"])
313
+ lifetime = 3600.0 if lifetime.nil? || lifetime.zero?
314
+ EXPIRY_KEYS.each do |key|
315
+ next if epoch_seconds(node[key]).nil?
316
+
317
+ node[key] = timestamp_like(node[key], now + lifetime)
318
+ return
319
+ end
320
+ return if number(node["expires_in"]).nil?
321
+
322
+ node["expires_in"] = lifetime.to_i
323
+ ISSUED_KEYS.each do |key|
324
+ next if epoch_seconds(node[key]).nil?
325
+
326
+ node[key] = timestamp_like(node[key], now)
327
+ return
328
+ end
329
+ end
330
+
331
+ # `seconds` in the shape `previous` used: epoch ms, epoch s, or ISO-8601.
332
+ def timestamp_like(previous, seconds)
333
+ value = number(previous)
334
+ unless value.nil?
335
+ stamp = value > 1e11 ? (seconds * 1000).to_i : seconds.to_i
336
+ return previous.is_a?(String) ? stamp.to_s : stamp
337
+ end
338
+ Time.at(seconds.to_i).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
339
+ end
340
+
341
+ # Replace the store atomically, keeping its permissions (it holds secrets).
342
+ def write_store(path, data)
343
+ target = File.realpath(path)
344
+ mode = File.stat(target).mode & 0o777
345
+ tmp = File.join(File.dirname(target), ".#{File.basename(target)}.#{Process.pid}.tmp")
346
+ begin
347
+ # Created owner-only: the token must never sit world-readable, even briefly.
348
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |handle|
349
+ handle.write("#{JSON.pretty_generate(data)}\n")
350
+ end
351
+ File.chmod(mode, tmp)
352
+ File.rename(tmp, target)
353
+ ensure
354
+ File.delete(tmp) if File.exist?(tmp)
355
+ end
356
+ end
357
+
358
+ def refresh_token(node)
359
+ REFRESH_TOKEN_KEYS.each { |key| return node[key] if token_string?(node[key]) }
360
+ nil
361
+ end
362
+
363
+ # The OAuth client the token was issued to: the store's own record, else
364
+ # the access token's JWT claims (client_id, then a string or first aud).
365
+ def client_id(node)
366
+ return node["client_id"] if token_string?(node["client_id"])
367
+
368
+ access = token_string?(node["access_token"]) ? node["access_token"] : node["access"]
369
+ claims = jwt_claims(access)
370
+ return claims["client_id"] if token_string?(claims["client_id"])
371
+
372
+ aud = claims["aud"]
373
+ aud = aud.find { |entry| token_string?(entry) } if aud.is_a?(Array)
374
+ token_string?(aud) ? aud : nil
375
+ end
376
+
377
+ def jwt_claims(token)
378
+ parts = token.is_a?(String) ? token.split(".") : []
379
+ return {} if parts.length < 2
380
+
381
+ claims = JSON.parse(parts[1].tr("-_", "+/").unpack1("m").force_encoding("UTF-8"))
382
+ claims.is_a?(Hash) ? claims : {}
383
+ rescue JSON::ParserError, ArgumentError, EncodingError
384
+ {}
385
+ end
386
+
199
387
  # The hash actually holding `access_token`: the document itself, or one of
200
388
  # the containers a login tool nests it under.
201
389
  def token_node(data)
202
- return data if token_string?(data["access_token"])
390
+ node = find_token_node(data)
391
+ return nil if node.nil?
392
+ return node if token_string?(node["access_token"])
393
+
394
+ node.merge("access_token" => node["access"])
395
+ end
396
+
397
+ def find_token_node(data)
398
+ return data if token_string?(data["access_token"]) || token_string?(data["access"])
203
399
 
204
400
  NESTED_KEYS.each do |key|
205
401
  nested = data[key]
206
- return nested if nested.is_a?(Hash) && token_string?(nested["access_token"])
402
+ next unless nested.is_a?(Hash)
403
+ return nested if token_string?(nested["access_token"]) || token_string?(nested["access"])
207
404
  end
208
405
  nil
209
406
  end
@@ -250,6 +447,27 @@ module Zer0ImageGenerator
250
447
  end
251
448
  end
252
449
 
450
+ # Aspect ratios the Imagine models take. grok-imagine renders a square
451
+ # unless told otherwise, which a wide banner then crops; grok-2-image
452
+ # predates the parameter, so it is sent nothing.
453
+ IMAGINE_PREFIX = "grok-imagine"
454
+ ASPECT_RATIOS = [
455
+ ["1:1", 1.0], ["3:2", 1.5], ["2:3", 2.0 / 3], ["4:3", 4.0 / 3],
456
+ ["3:4", 0.75], ["16:9", 16.0 / 9], ["9:16", 9.0 / 16]
457
+ ].freeze
458
+
459
+ # The Imagine aspect ratio nearest the configured WIDTHxHEIGHT size, or nil
460
+ # for a non-Imagine model or a size with no dimensions (e.g. `auto`).
461
+ def aspect_ratio(model, size)
462
+ return nil unless (model || "").to_s.strip.downcase.start_with?(IMAGINE_PREFIX)
463
+
464
+ match = /\A\s*(\d+)\s*[xX]\s*(\d+)\s*\z/.match(size.to_s)
465
+ return nil if match.nil? || match[1].to_i.zero? || match[2].to_i.zero?
466
+
467
+ wanted = Math.log(match[1].to_f / match[2].to_i)
468
+ ASPECT_RATIOS.min_by { |(_label, ratio)| (Math.log(ratio) - wanted).abs }[0]
469
+ end
470
+
253
471
  def number(raw)
254
472
  return raw.to_f if raw.is_a?(Numeric)
255
473
  return nil unless raw.is_a?(String) && !raw.empty?
@@ -266,5 +484,9 @@ module Zer0ImageGenerator
266
484
  def debug(message)
267
485
  Logging.debug(message) if defined?(Logging)
268
486
  end
487
+
488
+ def warn_log(message)
489
+ Logging.warn(message) if defined?(Logging)
490
+ end
269
491
  end
270
492
  end
@@ -258,19 +258,20 @@ module Zer0ImageGenerator
258
258
  base_prompt = @deps.build_prompt(cf, file_settings)
259
259
  prompt = base_prompt
260
260
  orchestrated = settings.provider != "local" # local is deterministic
261
- if orchestrated && file_settings.prompt_engine == "claude" && !settings.dry_run
262
- prompt = @deps.claude_article_brief(@ctx.claude, cf, file_settings, base_prompt)
261
+ if orchestrated && %w[claude xai].include?(file_settings.prompt_engine) && !settings.dry_run
262
+ prompt = @deps.claude_article_brief(@ctx.orchestrator(file_settings.prompt_engine),
263
+ cf, file_settings, base_prompt)
263
264
  end
264
265
  debug("Generated prompt: #{prompt[0, 500]}...")
265
266
 
266
267
  if settings.dry_run
267
268
  info("[DRY RUN] Would generate image:")
268
269
  say(" Provider: #{settings.provider}")
269
- if orchestrated && file_settings.prompt_engine == "claude"
270
- say(" Prompt engine: claude (article analysis runs at generation time)")
270
+ if orchestrated && %w[claude xai].include?(file_settings.prompt_engine)
271
+ say(" Prompt engine: #{file_settings.prompt_engine} (article analysis runs at generation time)")
271
272
  end
272
- if orchestrated && file_settings.review_engine == "claude"
273
- say(" Review: claude (image review runs at generation time)")
273
+ if orchestrated && %w[claude xai].include?(file_settings.review_engine)
274
+ say(" Review: #{file_settings.review_engine} (image review runs at generation time)")
274
275
  end
275
276
  say(" Output: #{out_base.sub_ext('.png')}")
276
277
  say(" Preview path: #{@deps.preview_front_matter_path(file_settings, slug + '.png')}")
@@ -291,10 +292,11 @@ module Zer0ImageGenerator
291
292
  result = provider.generate(prompt, file_settings, out_base, file_ctx)
292
293
 
293
294
  # ---- Review: Claude inspects the render; at most ONE regeneration ----
294
- if orchestrated && file_settings.review_engine == "claude" &&
295
- result.ok && result.path && result.kind == "png"
295
+ if orchestrated && %w[claude xai].include?(file_settings.review_engine) &&
296
+ result.ok && result.path && %w[png jpg].include?(result.kind)
296
297
  approved, critique, revised =
297
- @deps.claude_review_image(@ctx.claude, result.path, cf, prompt, file_settings)
298
+ @deps.claude_review_image(@ctx.orchestrator(file_settings.review_engine),
299
+ result.path, cf, prompt, file_settings)
298
300
  if approved
299
301
  debug("Claude review: #{critique}") if critique && !critique.empty?
300
302
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zer0ImageGenerator
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zer0-image-generator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
@@ -58,8 +58,10 @@ files:
58
58
  - lib/zer0_image_generator/constants.rb
59
59
  - lib/zer0_image_generator/content.rb
60
60
  - lib/zer0_image_generator/engine.rb
61
+ - lib/zer0_image_generator/facade.rb
61
62
  - lib/zer0_image_generator/freesvg/cache.rb
62
63
  - lib/zer0_image_generator/freesvg/client.rb
64
+ - lib/zer0_image_generator/grok/client.rb
63
65
  - lib/zer0_image_generator/http.rb
64
66
  - lib/zer0_image_generator/library.rb
65
67
  - lib/zer0_image_generator/logging.rb