zer0-image-generator 0.6.0 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ # xAI (Grok) credential resolution, shared by the XAIProvider.
7
+ #
8
+ # api.x.ai takes a plain `Authorization: Bearer <value>` for BOTH kinds of
9
+ # credential, so the only thing that varies between an OAuth access token and a
10
+ # console API key is where the value comes from and what we tell the human when
11
+ # it is rejected. This file owns that chain; the provider owns the request.
12
+ #
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.
19
+ module Zer0ImageGenerator
20
+ # One resolved way to authenticate against api.x.ai.
21
+ class XAICredential
22
+ attr_reader :mode, :token, :source
23
+
24
+ # mode: "oauth" | "api_key" — which rung of the chain this is.
25
+ # source: human label for logs and fallback warnings.
26
+ def initialize(mode, token, source)
27
+ @mode = mode
28
+ @token = token
29
+ @source = source
30
+ end
31
+
32
+ def headers
33
+ { "Authorization" => "Bearer #{@token}" }
34
+ end
35
+ end
36
+
37
+ module XAIAuth
38
+ API_BASE = "https://api.x.ai/v1"
39
+
40
+ # OAuth access token, straight from the environment. XAI_ first (it matches
41
+ # the vendor's own XAI_* naming), GROK_ accepted because that is what the
42
+ # consumer-facing login is called.
43
+ OAUTH_TOKEN_ENV = %w[XAI_OAUTH_TOKEN GROK_OAUTH_TOKEN].freeze
44
+
45
+ # Explicit path to a credentials file written by a Grok OAuth login.
46
+ CREDENTIALS_FILE_ENV = %w[XAI_OAUTH_CREDENTIALS GROK_OAUTH_CREDENTIALS].freeze
47
+
48
+ API_KEY_ENV = "XAI_API_KEY"
49
+
50
+ # Where a Grok login drops its token when no path is configured. Relative to
51
+ # the home directory so a test (and a container) can point HOME elsewhere.
52
+ DEFAULT_CREDENTIALS_FILES = [
53
+ ".grok/auth.json", ".config/grok/auth.json", ".xai/auth.json"
54
+ ].freeze
55
+
56
+ # Credential files nest the token differently per tool; look at the top
57
+ # level first, then inside the usual containers.
58
+ NESTED_KEYS = %w[xai grok oauth tokens credentials].freeze
59
+
60
+ # Treat a token that expires within the minute as already gone — a run can
61
+ # easily outlive that, and the API key fallback is cheaper than a mid-run
62
+ # 401 on every remaining page.
63
+ EXPIRY_LEEWAY = 60.0
64
+
65
+ # Which rung(s) to try, as an env knob. `auto` (the default) is OAuth first
66
+ # with the API key as the fallback; the other two pin one rung.
67
+ AUTH_MODE_ENV = "XAI_AUTH"
68
+ AUTH_MODES = %w[auto oauth api_key].freeze
69
+
70
+ MISSING_HINT =
71
+ "The xAI provider needs a Grok OAuth token or an xAI API key — any ONE of:\n" \
72
+ " 1. XAI_OAUTH_TOKEN (or GROK_OAUTH_TOKEN) — an OAuth access token from a\n" \
73
+ " Grok login (SuperGrok / X Premium+)\n" \
74
+ " 2. a Grok OAuth credentials file — ~/.grok/auth.json by default, or the\n" \
75
+ " path in XAI_OAUTH_CREDENTIALS / GROK_OAUTH_CREDENTIALS\n" \
76
+ " 3. XAI_API_KEY — a key from console.x.ai (also the automatic fallback\n" \
77
+ " when an OAuth token is expired or rejected)\n" \
78
+ "Pin one rung with XAI_AUTH=oauth|api_key; the default tries OAuth first."
79
+
80
+ module_function
81
+
82
+ # The credentials to try, best rung first. An empty list means the provider
83
+ # is unconfigured.
84
+ def chain(env)
85
+ mode = auth_mode(env)
86
+ credentials = []
87
+ credentials.concat(oauth_credentials(env)) unless mode == "api_key"
88
+ credentials << api_key_credential(env) unless mode == "oauth"
89
+ credentials.compact
90
+ end
91
+
92
+ # One-line summary of how this run will authenticate (nil when it cannot).
93
+ def describe(env)
94
+ credentials = chain(env)
95
+ return nil if credentials.empty?
96
+
97
+ primary = credentials[0].source
98
+ fallback = credentials[1]
99
+ fallback ? "#{primary}, falling back to #{fallback.source}" : primary
100
+ end
101
+
102
+ # Base for the images endpoint. XAI_BASE_URL is xAI's own override and is
103
+ # honored here so an OAuth surface on a different host stays reachable.
104
+ def base_url(env)
105
+ configured = env["XAI_BASE_URL"]
106
+ return API_BASE if configured.nil? || configured.empty?
107
+
108
+ configured.chomp("/")
109
+ end
110
+
111
+ def auth_mode(env)
112
+ raw = (env[AUTH_MODE_ENV] || "").strip.downcase
113
+ return "auto" if raw.empty?
114
+ return raw if AUTH_MODES.include?(raw)
115
+
116
+ debug("#{AUTH_MODE_ENV}=#{raw} is not one of #{AUTH_MODES.join('|')}; using auto")
117
+ "auto"
118
+ end
119
+
120
+ def oauth_credentials(env)
121
+ OAUTH_TOKEN_ENV.each do |name|
122
+ value = env[name]
123
+ next if value.nil? || value.empty?
124
+
125
+ return [XAICredential.new("oauth", value, "#{name} (Grok OAuth token)")]
126
+ end
127
+ credential_files(env).each do |path|
128
+ credential = file_credential(path)
129
+ return [credential] if credential
130
+ end
131
+ []
132
+ end
133
+
134
+ def api_key_credential(env)
135
+ value = env[API_KEY_ENV]
136
+ return nil if value.nil? || value.empty?
137
+
138
+ XAICredential.new("api_key", value, "#{API_KEY_ENV} (xAI API key)")
139
+ end
140
+
141
+ # Candidate credential-file paths: an explicit override wins outright, so a
142
+ # configured-but-missing path never silently reads someone else's token.
143
+ def credential_files(env)
144
+ CREDENTIALS_FILE_ENV.each do |name|
145
+ value = env[name]
146
+ return [value] unless value.nil? || value.empty?
147
+ end
148
+ home = home_dir(env)
149
+ return [] if home.nil? || home.empty?
150
+
151
+ DEFAULT_CREDENTIALS_FILES.map { |rel| File.join(home, rel) }
152
+ end
153
+
154
+ # The env snapshot is the source of truth (tests and containers set HOME);
155
+ # Dir.home is only the last resort.
156
+ def home_dir(env)
157
+ %w[HOME USERPROFILE].each do |name|
158
+ value = env[name]
159
+ return value unless value.nil? || value.empty?
160
+ end
161
+ begin
162
+ Dir.home
163
+ rescue ArgumentError, SystemCallError
164
+ # No resolvable home (empty env in a container) — no default file to read.
165
+ nil
166
+ end
167
+ end
168
+
169
+ # Read one credentials file. Anything unreadable, unparseable, tokenless or
170
+ # expired is "no credential here" — the chain moves on to the next rung.
171
+ def file_credential(path)
172
+ return nil unless File.file?(path)
173
+
174
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
175
+ unless data.is_a?(Hash)
176
+ debug("xAI OAuth: #{path} is not a JSON object; ignoring")
177
+ return nil
178
+ end
179
+ node = token_node(data)
180
+ if node.nil?
181
+ debug("xAI OAuth: no access_token in #{path}; ignoring")
182
+ return nil
183
+ end
184
+ if expired?(node)
185
+ debug("xAI OAuth: the token in #{path} has expired; re-run your Grok login")
186
+ return nil
187
+ end
188
+
189
+ XAICredential.new("oauth", node["access_token"].to_s,
190
+ "Grok OAuth token (#{path})")
191
+ rescue JSON::ParserError => exc
192
+ debug("xAI OAuth: #{path} is not valid JSON (#{exc.message}); ignoring")
193
+ nil
194
+ rescue SystemCallError, IOError => exc
195
+ debug("xAI OAuth: cannot read #{path} (#{exc.message}); ignoring")
196
+ nil
197
+ end
198
+
199
+ # The hash actually holding `access_token`: the document itself, or one of
200
+ # the containers a login tool nests it under.
201
+ def token_node(data)
202
+ return data if token_string?(data["access_token"])
203
+
204
+ NESTED_KEYS.each do |key|
205
+ nested = data[key]
206
+ return nested if nested.is_a?(Hash) && token_string?(nested["access_token"])
207
+ end
208
+ nil
209
+ end
210
+
211
+ def token_string?(value)
212
+ value.is_a?(String) && !value.empty?
213
+ end
214
+
215
+ def expired?(node, now = Time.now.to_f)
216
+ expires_at = absolute_expiry(node)
217
+ return false if expires_at.nil?
218
+
219
+ expires_at <= now + EXPIRY_LEEWAY
220
+ end
221
+
222
+ # Absolute expiry in epoch seconds, or nil when the file records none.
223
+ def absolute_expiry(node)
224
+ %w[expires_at expiry expiration expires].each do |key|
225
+ value = epoch_seconds(node[key])
226
+ return value unless value.nil?
227
+ end
228
+ # `expires_in` is relative and only means something next to an issue time.
229
+ lifetime = number(node["expires_in"])
230
+ return nil if lifetime.nil?
231
+
232
+ %w[created_at obtained_at issued_at issued].each do |key|
233
+ issued = epoch_seconds(node[key])
234
+ return issued + lifetime unless issued.nil?
235
+ end
236
+ nil
237
+ end
238
+
239
+ # Accepts epoch seconds, epoch milliseconds, or an ISO-8601 timestamp.
240
+ def epoch_seconds(raw)
241
+ value = number(raw)
242
+ # 1e11 seconds is the year 5138; anything past it is milliseconds.
243
+ return value > 1e11 ? value / 1000.0 : value unless value.nil?
244
+ return nil unless raw.is_a?(String) && !raw.empty?
245
+
246
+ begin
247
+ Time.iso8601(raw).to_f
248
+ rescue ArgumentError
249
+ nil
250
+ end
251
+ end
252
+
253
+ def number(raw)
254
+ return raw.to_f if raw.is_a?(Numeric)
255
+ return nil unless raw.is_a?(String) && !raw.empty?
256
+
257
+ begin
258
+ Float(raw)
259
+ rescue ArgumentError, TypeError
260
+ nil
261
+ end
262
+ end
263
+
264
+ # Logging is a sibling slice resolved at call time (see providers/base.rb);
265
+ # never let a diagnostic take down credential resolution.
266
+ def debug(message)
267
+ Logging.debug(message) if defined?(Logging)
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "providers/base"
4
+ require_relative "providers/openai"
5
+ require_relative "providers/xai"
6
+ require_relative "providers/stability"
7
+ require_relative "providers/gemini"
8
+ require_relative "providers/local"
9
+
10
+ module Zer0ImageGenerator
11
+ # Renderers only — Claude is the orchestration layer (article brief + image
12
+ # review) that sits in front of ANY of these, not a provider itself. Keyed by
13
+ # provider name so the runner can look one up by the resolved `provider`
14
+ # setting.
15
+ PROVIDERS = [
16
+ OpenAIProvider.new,
17
+ XAIProvider.new,
18
+ StabilityProvider.new,
19
+ GeminiProvider.new,
20
+ LocalProvider.new,
21
+ ].each_with_object({}) { |provider, registry| registry[provider.name] = provider }.freeze
22
+ end