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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- 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
|