ai-lite 0.2.0 → 0.4.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: 2fbb65bf2c01cdc7950a76cc5a273c0b495174bb9d671f7b5d32d49a79274b34
4
- data.tar.gz: e8e909ff9c107f552e1f6a81772b3800bb2b1904bf9a5da50cfe6a2ca2380bd4
3
+ metadata.gz: 4f75f42caf4d74f3abf2ec982b7c3830c715b1d1c54f2ae0fdf02a1dec5151db
4
+ data.tar.gz: ff953a4533862067eee149c04ac5f348bf3b97e7e9f7b87fbcd18a3fe3d63233
5
5
  SHA512:
6
- metadata.gz: ef2a0f01ea606b121b583134f6026c7d23388b3a58732a5a1f197275cb7178d2547ffea5040956955fd39452f944f1703833a4ba4925d0d2314ef2693372b904
7
- data.tar.gz: 272108490b47700c06f49fb817d684fa6a2dbd003e02eff35fa59f19db8fe120e41983d941c32a50c640ef6776aee00038bc5ed841bef60cdde04fee854ea0c4
6
+ metadata.gz: c2c91c198575156c15e0100c3fa12709b1cf09395785d4a634e46bf06e9bc9dbf9e8073a95e990f54c4e19a0b72322379317299443899d83eef5c587864b6781
7
+ data.tar.gz: c528951faf3fe8f453def5a8dbaf7eb00c7da77fc5135c22ccfe2ac5765a2603a0c5cf50dcdfcbe1dda7aaedf6f69ca210a19424b67ef2866c627cd40e17104e
data/README.md CHANGED
@@ -18,6 +18,8 @@ It is not meant to replace the official OpenAI SDK. It is a small wrapper for pr
18
18
  ```ruby
19
19
  ai.chat("Say hello")
20
20
  ai.moderate("User submitted text")
21
+ ai.embed("Text to vectorize")
22
+ ai.image("A simple app icon")
21
23
  ```
22
24
 
23
25
  ## Usage
@@ -49,6 +51,8 @@ AiLite.configure do |config|
49
51
  config.api_key = ENV["OPENAI_API_KEY"]
50
52
  config.model = "gpt-5.5"
51
53
  config.moderation_model = "omni-moderation-latest"
54
+ config.embedding_model = "text-embedding-3-small"
55
+ config.image_model = "gpt-image-2"
52
56
  config.timeout = 120
53
57
  config.max_output_tokens = 2000
54
58
  end
@@ -95,16 +99,43 @@ The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
95
99
 
96
100
  ## Moderation
97
101
 
98
- Use `moderate` to classify text or images for potentially harmful content:
102
+ Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
103
+
104
+ A common use case is checking a comment before it is shown publicly:
99
105
 
100
106
  ```ruby
101
- result = ai.moderate("User submitted text")
107
+ comment = "User submitted comment"
108
+ result = ai.moderate(comment)
102
109
 
103
110
  if result["content"]["flagged"]
104
- # hide, block, or route to review
111
+ # hide, block, or route the comment to review
112
+ else
113
+ # publish the comment
105
114
  end
106
115
  ```
107
116
 
117
+ Moderation responses include an overall `flagged` value plus category booleans and scores:
118
+
119
+ ```ruby
120
+ {
121
+ "content" => {
122
+ "flagged" => false,
123
+ "categories" => {
124
+ "hate" => false,
125
+ "violence" => false
126
+ },
127
+ "category_scores" => {
128
+ "hate" => 0.01,
129
+ "violence" => 0.02
130
+ }
131
+ },
132
+ "response_id" => "modr_...",
133
+ "status" => 200,
134
+ "error" => nil,
135
+ "raw" => nil
136
+ }
137
+ ```
138
+
108
139
  `moderate` sends a `POST` request to `/v1/moderations` with:
109
140
 
110
141
  - `model`
@@ -144,6 +175,135 @@ result = ai.moderate([
144
175
  ])
145
176
  ```
146
177
 
178
+ ## Embeddings
179
+
180
+ Use `embed` to turn text into vectors that can be stored and compared for semantic search, recommendations, duplicate detection, or retrieval-augmented generation.
181
+
182
+ ```ruby
183
+ result = ai.embed("How do I reset my password?")
184
+ vector = result["content"]
185
+ ```
186
+
187
+ For one string input, `content` is the embedding vector:
188
+
189
+ ```ruby
190
+ {
191
+ "content" => [0.012, -0.44, 0.203],
192
+ "response_id" => nil,
193
+ "status" => 200,
194
+ "error" => nil,
195
+ "raw" => nil
196
+ }
197
+ ```
198
+
199
+ For multiple inputs, `content` is an array of vectors in the same order:
200
+
201
+ ```ruby
202
+ result = ai.embed([
203
+ "How do I reset my password?",
204
+ "How do I update my billing card?"
205
+ ])
206
+
207
+ vectors = result["content"]
208
+ first_vector = vectors[0]
209
+ ```
210
+
211
+ ```ruby
212
+ {
213
+ "content" => [
214
+ [0.012, -0.44, 0.203],
215
+ [0.332, 0.021, -0.118]
216
+ ],
217
+ "response_id" => nil,
218
+ "status" => 200,
219
+ "error" => nil,
220
+ "raw" => nil
221
+ }
222
+ ```
223
+
224
+ `embed` sends a `POST` request to `/v1/embeddings` with:
225
+
226
+ - `model`
227
+ - `input`
228
+ - optional `dimensions`
229
+ - optional `encoding_format`
230
+ - optional `debug`
231
+ - optional extra `options`
232
+
233
+ The default embedding model is `text-embedding-3-small`.
234
+
235
+ Pass `debug: true` to include the raw OpenAI response, including token usage:
236
+
237
+ ```ruby
238
+ result = ai.embed("Hello", debug: true)
239
+
240
+ result["content"] # embedding vector
241
+ result["raw"]["usage"] # token usage
242
+ ```
243
+
244
+ ## Images
245
+
246
+ Use `image` to generate an image from a prompt.
247
+
248
+ ```ruby
249
+ result = ai.image("A clean Ruby gem logo on a white background")
250
+ image_data = result["content"]
251
+ ```
252
+
253
+ By default, `content` is the base64-encoded generated image:
254
+
255
+ ```ruby
256
+ {
257
+ "content" => "iVBORw0KGgo...",
258
+ "response_id" => nil,
259
+ "status" => 200,
260
+ "error" => nil,
261
+ "raw" => nil
262
+ }
263
+ ```
264
+
265
+ Write the generated image bytes directly to a file with `output_path`:
266
+
267
+ ```ruby
268
+ result = ai.image(
269
+ "A clean Ruby gem logo on a white background",
270
+ output_path: "tmp/logo.png"
271
+ )
272
+ ```
273
+
274
+ `image` sends a `POST` request to `/v1/images/generations` with:
275
+
276
+ - `model`
277
+ - `prompt`
278
+ - optional `size`
279
+ - optional `quality`
280
+ - optional `background`
281
+ - optional `output_format`
282
+ - optional `debug`
283
+ - optional extra `options`
284
+
285
+ The default image model is `gpt-image-2`.
286
+
287
+ Use `output_format` to request `png`, `webp`, or `jpeg` output:
288
+
289
+ ```ruby
290
+ result = ai.image(
291
+ "A transparent app icon",
292
+ background: "transparent",
293
+ output_format: "webp",
294
+ output_path: "tmp/icon.webp"
295
+ )
296
+ ```
297
+
298
+ Pass `debug: true` to include the raw OpenAI response, including usage when returned:
299
+
300
+ ```ruby
301
+ result = ai.image("A tiny robot sticker", debug: true)
302
+
303
+ result["content"] # base64 image data
304
+ result["raw"]["usage"] # token usage, when returned
305
+ ```
306
+
147
307
  ## Multi-Turn Chat
148
308
 
149
309
  Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
@@ -189,22 +349,6 @@ JSON-looking model output:
189
349
  }
190
350
  ```
191
351
 
192
- Moderation output:
193
-
194
- ```ruby
195
- {
196
- "content" => {
197
- "flagged" => false,
198
- "categories" => { ... },
199
- "category_scores" => { ... }
200
- },
201
- "response_id" => "modr_...",
202
- "status" => 200,
203
- "error" => nil,
204
- "raw" => nil
205
- }
206
- ```
207
-
208
352
  Failure:
209
353
 
210
354
  ```ruby
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.2.0".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -8,6 +8,8 @@ class AiLite
8
8
  API_BASE_URL = "https://api.openai.com/v1".freeze
9
9
  DEFAULT_MODEL = "gpt-5.5".freeze
10
10
  DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
12
+ DEFAULT_IMAGE_MODEL = "gpt-image-2".freeze
11
13
  DEFAULT_TIMEOUT = 120
12
14
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
13
15
  IMAGE_MIME_TYPES = {
@@ -19,12 +21,14 @@ class AiLite
19
21
  }.freeze
20
22
 
21
23
  class Configuration
22
- attr_accessor :api_key, :model, :moderation_model, :timeout, :max_output_tokens
24
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens
23
25
 
24
26
  def initialize
25
27
  @api_key = nil
26
28
  @model = DEFAULT_MODEL
27
29
  @moderation_model = DEFAULT_MODERATION_MODEL
30
+ @embedding_model = DEFAULT_EMBEDDING_MODEL
31
+ @image_model = DEFAULT_IMAGE_MODEL
28
32
  @timeout = DEFAULT_TIMEOUT
29
33
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
30
34
  end
@@ -59,19 +63,29 @@ class AiLite
59
63
  client.moderate(input, **kwargs)
60
64
  end
61
65
 
66
+ def embed(input, **kwargs)
67
+ client.embed(input, **kwargs)
68
+ end
69
+
70
+ def image(prompt, **kwargs)
71
+ client.image(prompt, **kwargs)
72
+ end
73
+
62
74
  def reset_client!
63
75
  @client = nil
64
76
  end
65
77
  end
66
78
 
67
- attr_reader :api_key, :model, :moderation_model, :timeout, :max_output_tokens, :headers
79
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens, :headers
68
80
 
69
- def initialize(api_key: nil, model: nil, moderation_model: nil, timeout: nil, max_output_tokens: nil)
81
+ def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, timeout: nil, max_output_tokens: nil)
70
82
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
71
83
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
72
84
 
73
85
  @model = model || self.class.configuration.model
74
86
  @moderation_model = moderation_model || self.class.configuration.moderation_model
87
+ @embedding_model = embedding_model || self.class.configuration.embedding_model
88
+ @image_model = image_model || self.class.configuration.image_model
75
89
  @timeout = timeout || self.class.configuration.timeout
76
90
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
77
91
  @headers = {
@@ -104,6 +118,34 @@ class AiLite
104
118
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
105
119
  end
106
120
 
121
+ def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
122
+ payload = options.merge(
123
+ model: model || embedding_model,
124
+ input: input
125
+ )
126
+ payload[:dimensions] = dimensions if dimensions
127
+ payload[:encoding_format] = encoding_format if encoding_format
128
+
129
+ extract_embedding(post(payload, endpoint: embedding_endpoint), multiple: input.is_a?(Array), debug: debug)
130
+ rescue => e
131
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
132
+ end
133
+
134
+ def image(prompt, model: nil, size: nil, quality: nil, background: nil, output_format: nil, output_path: nil, debug: false, options: {})
135
+ payload = options.merge(
136
+ model: model || image_model,
137
+ prompt: prompt
138
+ )
139
+ payload[:size] = size if size
140
+ payload[:quality] = quality if quality
141
+ payload[:background] = background if background
142
+ payload[:output_format] = output_format if output_format
143
+
144
+ extract_image(post(payload, endpoint: image_endpoint), output_path: output_path, debug: debug)
145
+ rescue => e
146
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
147
+ end
148
+
107
149
  private
108
150
 
109
151
  def post(payload, endpoint: response_endpoint)
@@ -131,6 +173,14 @@ class AiLite
131
173
  "#{API_BASE_URL}/moderations"
132
174
  end
133
175
 
176
+ def embedding_endpoint
177
+ "#{API_BASE_URL}/embeddings"
178
+ end
179
+
180
+ def image_endpoint
181
+ "#{API_BASE_URL}/images/generations"
182
+ end
183
+
134
184
  def extract_content(response, debug: false)
135
185
  status = response.code.to_i
136
186
  parsed_response = JSON.parse(response.body)
@@ -187,6 +237,63 @@ class AiLite
187
237
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
188
238
  end
189
239
 
240
+ def extract_embedding(response, multiple:, debug: false)
241
+ status = response.code.to_i
242
+ parsed_response = JSON.parse(response.body)
243
+
244
+ unless success_status?(status)
245
+ return prettify_data(
246
+ status: status,
247
+ error: error_message(parsed_response),
248
+ response_id: parsed_response["id"],
249
+ raw: parsed_response,
250
+ debug: debug
251
+ )
252
+ end
253
+
254
+ prettify_data(
255
+ status: status,
256
+ content: embedding_content(parsed_response, multiple: multiple),
257
+ response_id: parsed_response["id"],
258
+ raw: parsed_response,
259
+ debug: debug
260
+ )
261
+ rescue JSON::ParserError => e
262
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
263
+ rescue => e
264
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
265
+ end
266
+
267
+ def extract_image(response, output_path:, debug: false)
268
+ status = response.code.to_i
269
+ parsed_response = JSON.parse(response.body)
270
+
271
+ unless success_status?(status)
272
+ return prettify_data(
273
+ status: status,
274
+ error: error_message(parsed_response),
275
+ response_id: parsed_response["id"],
276
+ raw: parsed_response,
277
+ debug: debug
278
+ )
279
+ end
280
+
281
+ content = image_content(parsed_response)
282
+ write_image_output(output_path, content) if output_path
283
+
284
+ prettify_data(
285
+ status: status,
286
+ content: content,
287
+ response_id: parsed_response["id"],
288
+ raw: parsed_response,
289
+ debug: debug
290
+ )
291
+ rescue JSON::ParserError => e
292
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
293
+ rescue => e
294
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
295
+ end
296
+
190
297
  def extract_output_text(raw)
191
298
  Array(raw["output"]).flat_map do |item|
192
299
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -253,6 +360,25 @@ class AiLite
253
360
  results.length == 1 ? results.first : results
254
361
  end
255
362
 
363
+ def embedding_content(raw, multiple:)
364
+ embeddings = Array(raw["data"]).map do |item|
365
+ item["embedding"] if item.is_a?(Hash)
366
+ end.compact
367
+
368
+ multiple ? embeddings : embeddings.first
369
+ end
370
+
371
+ def image_content(raw)
372
+ image = Array(raw["data"]).find { |item| item.is_a?(Hash) && item["b64_json"] }
373
+ image && image["b64_json"]
374
+ end
375
+
376
+ def write_image_output(path, content)
377
+ raise "No image data returned" if content.to_s.empty?
378
+
379
+ File.binwrite(path, Base64.decode64(content))
380
+ end
381
+
256
382
  def success_status?(status)
257
383
  status >= 200 && status < 300
258
384
  end
data/test/ai_lite_test.rb CHANGED
@@ -33,12 +33,15 @@ class AiLiteTest < Minitest::Test
33
33
  client = AiLite.new(
34
34
  api_key: "explicit-key",
35
35
  model: "gpt-test",
36
+ image_model: "gpt-image-test",
36
37
  timeout: 10
37
38
  )
38
39
 
39
40
  assert_equal "explicit-key", client.api_key
40
41
  assert_equal "gpt-test", client.model
41
42
  assert_equal "omni-moderation-latest", client.moderation_model
43
+ assert_equal "text-embedding-3-small", client.embedding_model
44
+ assert_equal "gpt-image-test", client.image_model
42
45
  assert_equal 10, client.timeout
43
46
  assert_equal 2000, client.max_output_tokens
44
47
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -52,6 +55,8 @@ class AiLiteTest < Minitest::Test
52
55
  config.api_key = "configured-key"
53
56
  config.model = "gpt-config"
54
57
  config.moderation_model = "omni-moderation-test"
58
+ config.embedding_model = "text-embedding-test"
59
+ config.image_model = "gpt-image-test"
55
60
  config.timeout = 15
56
61
  config.max_output_tokens = 750
57
62
  end
@@ -61,6 +66,8 @@ class AiLiteTest < Minitest::Test
61
66
  assert_equal "configured-key", client.api_key
62
67
  assert_equal "gpt-config", client.model
63
68
  assert_equal "omni-moderation-test", client.moderation_model
69
+ assert_equal "text-embedding-test", client.embedding_model
70
+ assert_equal "gpt-image-test", client.image_model
64
71
  assert_equal 15, client.timeout
65
72
  assert_equal 750, client.max_output_tokens
66
73
  assert_same client, AiLite.client
@@ -83,6 +90,8 @@ class AiLiteTest < Minitest::Test
83
90
  config.api_key = "configured-key"
84
91
  config.model = "gpt-config"
85
92
  config.moderation_model = "omni-moderation-config"
93
+ config.embedding_model = "text-embedding-config"
94
+ config.image_model = "gpt-image-config"
86
95
  config.timeout = 15
87
96
  config.max_output_tokens = 750
88
97
  end
@@ -91,6 +100,8 @@ class AiLiteTest < Minitest::Test
91
100
  api_key: "explicit-key",
92
101
  model: "gpt-explicit",
93
102
  moderation_model: "omni-moderation-explicit",
103
+ embedding_model: "text-embedding-explicit",
104
+ image_model: "gpt-image-explicit",
94
105
  timeout: 5,
95
106
  max_output_tokens: 300
96
107
  )
@@ -98,6 +109,8 @@ class AiLiteTest < Minitest::Test
98
109
  assert_equal "explicit-key", client.api_key
99
110
  assert_equal "gpt-explicit", client.model
100
111
  assert_equal "omni-moderation-explicit", client.moderation_model
112
+ assert_equal "text-embedding-explicit", client.embedding_model
113
+ assert_equal "gpt-image-explicit", client.image_model
101
114
  assert_equal 5, client.timeout
102
115
  assert_equal 300, client.max_output_tokens
103
116
  end
@@ -427,6 +440,210 @@ class AiLiteTest < Minitest::Test
427
440
  assert_nil result["raw"]
428
441
  end
429
442
 
443
+ def test_embed_sends_post_to_embeddings_with_default_payload
444
+ client = AiLite.new(api_key: "token-abc")
445
+
446
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |captured, _response|
447
+ result = client.embed("Text to vectorize")
448
+ request = captured[:http].last_request
449
+ payload = JSON.parse(request.body)
450
+
451
+ assert_equal [0.12, -0.34, 0.56], result["content"]
452
+ assert_nil result["response_id"]
453
+ assert_equal 200, result["status"]
454
+ assert_nil result["error"]
455
+ assert_nil result["raw"]
456
+ assert_equal "api.openai.com", captured[:host]
457
+ assert_equal 443, captured[:port]
458
+ assert_equal true, captured[:use_ssl]
459
+ assert_instance_of Net::HTTP::Post, request
460
+ assert_equal "/v1/embeddings", request.path
461
+ assert_equal "Bearer token-abc", request["Authorization"]
462
+ assert_equal "application/json", request["Content-Type"]
463
+ assert_equal "text-embedding-3-small", payload["model"]
464
+ assert_equal "Text to vectorize", payload["input"]
465
+ end
466
+ end
467
+
468
+ def test_embed_returns_vector_list_for_multiple_inputs
469
+ client = AiLite.new(api_key: "token-abc")
470
+ embeddings = [
471
+ [0.11, 0.22, 0.33],
472
+ [0.44, 0.55, 0.66]
473
+ ]
474
+
475
+ with_stubbed_http(embedding_response(embeddings)) do |captured, _response|
476
+ result = client.embed(["First text", "Second text"])
477
+ payload = JSON.parse(captured[:http].last_request.body)
478
+
479
+ assert_equal ["First text", "Second text"], payload["input"]
480
+ assert_equal embeddings, result["content"]
481
+ assert_equal 200, result["status"]
482
+ assert_nil result["error"]
483
+ end
484
+ end
485
+
486
+ def test_embed_includes_options_dimensions_encoding_format_and_model
487
+ client = AiLite.new(api_key: "token-abc")
488
+
489
+ with_stubbed_http(embedding_response([[0.12, -0.34]])) do |captured, _response|
490
+ client.embed(
491
+ "Short text",
492
+ model: "text-embedding-3-large",
493
+ dimensions: 2,
494
+ encoding_format: "float",
495
+ options: {
496
+ user: "user-123"
497
+ }
498
+ )
499
+ payload = JSON.parse(captured[:http].last_request.body)
500
+
501
+ assert_equal "text-embedding-3-large", payload["model"]
502
+ assert_equal "Short text", payload["input"]
503
+ assert_equal 2, payload["dimensions"]
504
+ assert_equal "float", payload["encoding_format"]
505
+ assert_equal "user-123", payload["user"]
506
+ end
507
+ end
508
+
509
+ def test_embed_uses_class_level_configured_client
510
+ AiLite.configure do |config|
511
+ config.api_key = "configured-key"
512
+ config.embedding_model = "text-embedding-config"
513
+ end
514
+
515
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |captured, _response|
516
+ AiLite.embed("Use configured defaults")
517
+ payload = JSON.parse(captured[:http].last_request.body)
518
+
519
+ assert_equal "text-embedding-config", payload["model"]
520
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
521
+ end
522
+ end
523
+
524
+ def test_embed_debug_true_returns_raw_usage
525
+ client = AiLite.new(api_key: "token-abc")
526
+
527
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |_captured, _response|
528
+ result = client.embed("Text to vectorize", debug: true)
529
+
530
+ assert_equal [0.12, -0.34, 0.56], result["content"]
531
+ assert_equal({ "prompt_tokens" => 4, "total_tokens" => 4 }, result["raw"]["usage"])
532
+ end
533
+ end
534
+
535
+ def test_image_sends_post_to_images_with_default_payload
536
+ client = AiLite.new(api_key: "token-abc")
537
+ image_data = Base64.strict_encode64("fake image")
538
+
539
+ with_stubbed_http(image_response(b64_json: image_data)) do |captured, _response|
540
+ result = client.image("A small ruby gem logo")
541
+ request = captured[:http].last_request
542
+ payload = JSON.parse(request.body)
543
+
544
+ assert_equal image_data, result["content"]
545
+ assert_nil result["response_id"]
546
+ assert_equal 200, result["status"]
547
+ assert_nil result["error"]
548
+ assert_nil result["raw"]
549
+ assert_equal "api.openai.com", captured[:host]
550
+ assert_equal 443, captured[:port]
551
+ assert_equal true, captured[:use_ssl]
552
+ assert_instance_of Net::HTTP::Post, request
553
+ assert_equal "/v1/images/generations", request.path
554
+ assert_equal "Bearer token-abc", request["Authorization"]
555
+ assert_equal "application/json", request["Content-Type"]
556
+ assert_equal "gpt-image-2", payload["model"]
557
+ assert_equal "A small ruby gem logo", payload["prompt"]
558
+ end
559
+ end
560
+
561
+ def test_image_includes_options_size_quality_background_output_format_and_model
562
+ client = AiLite.new(api_key: "token-abc")
563
+
564
+ with_stubbed_http(image_response) do |captured, _response|
565
+ client.image(
566
+ "A transparent app icon",
567
+ model: "gpt-image-test",
568
+ size: "1024x1536",
569
+ quality: "high",
570
+ background: "transparent",
571
+ output_format: "webp",
572
+ options: {
573
+ moderation: "auto",
574
+ output_compression: 80
575
+ }
576
+ )
577
+ payload = JSON.parse(captured[:http].last_request.body)
578
+
579
+ assert_equal "gpt-image-test", payload["model"]
580
+ assert_equal "A transparent app icon", payload["prompt"]
581
+ assert_equal "1024x1536", payload["size"]
582
+ assert_equal "high", payload["quality"]
583
+ assert_equal "transparent", payload["background"]
584
+ assert_equal "webp", payload["output_format"]
585
+ assert_equal "auto", payload["moderation"]
586
+ assert_equal 80, payload["output_compression"]
587
+ end
588
+ end
589
+
590
+ def test_image_uses_class_level_configured_client
591
+ AiLite.configure do |config|
592
+ config.api_key = "configured-key"
593
+ config.image_model = "gpt-image-config"
594
+ end
595
+
596
+ with_stubbed_http(image_response) do |captured, _response|
597
+ AiLite.image("Use configured defaults")
598
+ payload = JSON.parse(captured[:http].last_request.body)
599
+
600
+ assert_equal "gpt-image-config", payload["model"]
601
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
602
+ end
603
+ end
604
+
605
+ def test_image_writes_decoded_content_to_output_path
606
+ client = AiLite.new(api_key: "token-abc")
607
+ image_data = Base64.strict_encode64("fake image")
608
+
609
+ Tempfile.create(["generated", ".png"]) do |file|
610
+ with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
611
+ result = client.image("A file output", output_path: file.path)
612
+
613
+ assert_equal image_data, result["content"]
614
+ assert_equal "fake image", File.binread(file.path)
615
+ end
616
+ end
617
+ end
618
+
619
+ def test_image_debug_true_returns_raw_usage
620
+ client = AiLite.new(api_key: "token-abc")
621
+ image_data = Base64.strict_encode64("fake image")
622
+
623
+ with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
624
+ result = client.image("A debuggable image", debug: true)
625
+
626
+ assert_equal image_data, result["content"]
627
+ assert_equal({ "input_tokens" => 10, "output_tokens" => 20, "total_tokens" => 30 }, result["raw"]["usage"])
628
+ end
629
+ end
630
+
631
+ def test_image_output_path_without_image_data_returns_standard_envelope
632
+ client = AiLite.new(api_key: "token-abc")
633
+
634
+ Tempfile.create(["generated", ".png"]) do |file|
635
+ with_stubbed_http(image_response(b64_json: nil)) do |_captured, _response|
636
+ result = client.image("A missing image", output_path: file.path)
637
+
638
+ assert_nil result["content"]
639
+ assert_nil result["response_id"]
640
+ assert_equal 200, result["status"]
641
+ assert_equal "No image data returned", result["error"]
642
+ assert_nil result["raw"]
643
+ end
644
+ end
645
+ end
646
+
430
647
  def test_http_errors_return_standard_envelope
431
648
  client = AiLite.new(api_key: "token-abc")
432
649
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -542,6 +759,45 @@ class AiLiteTest < Minitest::Test
542
759
  }
543
760
  end
544
761
 
762
+ def embedding_response(embeddings)
763
+ body = JSON.generate(
764
+ "object" => "list",
765
+ "data" => embeddings.each_with_index.map do |embedding, index|
766
+ {
767
+ "object" => "embedding",
768
+ "index" => index,
769
+ "embedding" => embedding
770
+ }
771
+ end,
772
+ "model" => "text-embedding-3-small",
773
+ "usage" => {
774
+ "prompt_tokens" => 4,
775
+ "total_tokens" => 4
776
+ }
777
+ )
778
+ FakeResponse.new("200", body)
779
+ end
780
+
781
+ def image_response(b64_json: Base64.strict_encode64("fake image"))
782
+ image = {}
783
+ image["b64_json"] = b64_json if b64_json
784
+
785
+ body = JSON.generate(
786
+ "created" => 1_713_833_628,
787
+ "background" => "opaque",
788
+ "data" => [image],
789
+ "output_format" => "png",
790
+ "quality" => "medium",
791
+ "size" => "1024x1024",
792
+ "usage" => {
793
+ "input_tokens" => 10,
794
+ "output_tokens" => 20,
795
+ "total_tokens" => 30
796
+ }
797
+ )
798
+ FakeResponse.new("200", body)
799
+ end
800
+
545
801
  def with_env(values)
546
802
  originals = {}
547
803
 
metadata CHANGED
@@ -1,18 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - William Basmayor
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: AI Lite is a dependency-light Ruby client for Rails apps and plain Ruby
14
14
  projects that need simple OpenAI Responses API calls.
15
- email:
15
+ email:
16
16
  executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
@@ -27,7 +27,7 @@ homepage: https://github.com/wbasmayor/ai-lite
27
27
  licenses:
28
28
  - MIT
29
29
  metadata: {}
30
- post_install_message:
30
+ post_install_message:
31
31
  rdoc_options: []
32
32
  require_paths:
33
33
  - lib
@@ -42,8 +42,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
42
42
  - !ruby/object:Gem::Version
43
43
  version: '0'
44
44
  requirements: []
45
- rubygems_version: 3.0.3.1
46
- signing_key:
45
+ rubygems_version: 3.5.22
46
+ signing_key:
47
47
  specification_version: 4
48
48
  summary: Minimal Ruby client for simple AI chat calls through the OpenAI Responses
49
49
  API.