ai-lite 0.3.0 → 0.5.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: cd1b2ead0a8ae48d9617b9e73460649f817fc158430e11cc7b9ac798a86846d2
4
- data.tar.gz: f3dad03f56f3907e537dc8c72d63bf7e57e069d4b28ef666151d348b98f6b3e6
3
+ metadata.gz: cb52e45732e37e363b41144d47cc4136dab1feb450890f10cf84f7cc15593b2a
4
+ data.tar.gz: 2a05669e6fccdf8da0d1a3a68dc21598806e6072de2fda487e9b436751add386
5
5
  SHA512:
6
- metadata.gz: 6e70409efd1d697b2578ace5b878e74ef4e6214efbcbae2c295aae066e0e9ce4887cd39c95fcdde8bc73e0f6b0cfe60434dc7f1ca2611c77e649cc645b94351a
7
- data.tar.gz: 1faba64c32c14038f18c1d50a2f73bd92a0c940d8b303b3eecb89c2193d944d3b189334c5ecde81f55e8268db01e01e562686b74f20111105f0c0fbbeea387c6
6
+ metadata.gz: 307ffdbea749aac0eeca74b678d8af55a96add4924e1ef5e5f9157a83138b0929adc2db3282d79a924a24929d8c532fe1742f8ecb801409cf4d5c38fff3639ee
7
+ data.tar.gz: a50f9ef6171f3ee7249fc46d02e77698820272df70fe3ddbef777f6ab3159879c92c6d6d5fd7eddf29ceda3387308480a2622f95772b85b21fdbc2037808eda7
data/README.md CHANGED
@@ -19,6 +19,8 @@ It is not meant to replace the official OpenAI SDK. It is a small wrapper for pr
19
19
  ai.chat("Say hello")
20
20
  ai.moderate("User submitted text")
21
21
  ai.embed("Text to vectorize")
22
+ ai.image("A simple app icon")
23
+ ai.speak("Read this aloud")
22
24
  ```
23
25
 
24
26
  ## Usage
@@ -51,6 +53,9 @@ AiLite.configure do |config|
51
53
  config.model = "gpt-5.5"
52
54
  config.moderation_model = "omni-moderation-latest"
53
55
  config.embedding_model = "text-embedding-3-small"
56
+ config.image_model = "gpt-image-2"
57
+ config.speech_model = "gpt-4o-mini-tts"
58
+ config.speech_voice = "alloy"
54
59
  config.timeout = 120
55
60
  config.max_output_tokens = 2000
56
61
  end
@@ -95,6 +100,23 @@ The default model is `gpt-5.5`.
95
100
 
96
101
  The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
97
102
 
103
+ ### Multi-Turn Chat
104
+
105
+ Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
106
+
107
+ ```ruby
108
+ first = ai.chat("Tell me a short joke.")
109
+
110
+ follow_up = ai.chat(
111
+ "Explain why that is funny.",
112
+ options: {
113
+ previous_response_id: first["response_id"]
114
+ }
115
+ )
116
+
117
+ puts follow_up["content"]
118
+ ```
119
+
98
120
  ## Moderation
99
121
 
100
122
  Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
@@ -239,21 +261,156 @@ result["content"] # embedding vector
239
261
  result["raw"]["usage"] # token usage
240
262
  ```
241
263
 
242
- ## Multi-Turn Chat
264
+ ## Images
243
265
 
244
- Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
266
+ Use `image` to generate an image from a prompt.
245
267
 
246
268
  ```ruby
247
- first = ai.chat("Tell me a short joke.")
269
+ result = ai.image("A clean Ruby gem logo on a white background")
270
+ image_data = result["content"]
271
+ ```
248
272
 
249
- follow_up = ai.chat(
250
- "Explain why that is funny.",
251
- options: {
252
- previous_response_id: first["response_id"]
253
- }
273
+ By default, `content` is the base64-encoded generated image:
274
+
275
+ ```ruby
276
+ {
277
+ "content" => "iVBORw0KGgo...",
278
+ "response_id" => nil,
279
+ "status" => 200,
280
+ "error" => nil,
281
+ "raw" => nil
282
+ }
283
+ ```
284
+
285
+ Write the generated image bytes directly to a file with `output_path`:
286
+
287
+ ```ruby
288
+ result = ai.image(
289
+ "A clean Ruby gem logo on a white background",
290
+ output_path: "tmp/logo.png"
254
291
  )
292
+ ```
255
293
 
256
- puts follow_up["content"]
294
+ `image` sends a `POST` request to `/v1/images/generations` with:
295
+
296
+ - `model`
297
+ - `prompt`
298
+ - optional `size`
299
+ - optional `quality`
300
+ - optional `background`
301
+ - optional `output_format`
302
+ - optional `debug`
303
+ - optional extra `options`
304
+
305
+ The default image model is `gpt-image-2`.
306
+
307
+ Use `output_format` to request `png`, `webp`, or `jpeg` output:
308
+
309
+ ```ruby
310
+ result = ai.image(
311
+ "A transparent app icon",
312
+ background: "transparent",
313
+ output_format: "webp",
314
+ output_path: "tmp/icon.webp"
315
+ )
316
+ ```
317
+
318
+ Pass `debug: true` to include the raw OpenAI response, including usage when returned:
319
+
320
+ ```ruby
321
+ result = ai.image("A tiny robot sticker", debug: true)
322
+
323
+ result["content"] # base64 image data
324
+ result["raw"]["usage"] # token usage, when returned
325
+ ```
326
+
327
+ ## Speech
328
+
329
+ Use `speak` to generate audio from text.
330
+
331
+ ```ruby
332
+ result = ai.speak("Hello from AI Lite")
333
+ audio_bytes = result["content"]
334
+ ```
335
+
336
+ By default, `content` is the raw audio bytes returned by OpenAI:
337
+
338
+ ```ruby
339
+ {
340
+ "content" => "...binary audio bytes...",
341
+ "response_id" => nil,
342
+ "status" => 200,
343
+ "error" => nil,
344
+ "raw" => nil
345
+ }
346
+ ```
347
+
348
+ Write the generated audio directly to a file with `output_path`:
349
+
350
+ ```ruby
351
+ result = ai.speak(
352
+ "Hello from AI Lite",
353
+ output_path: "tmp/hello.mp3"
354
+ )
355
+ ```
356
+
357
+ When `output_path` is used, `content` is file metadata:
358
+
359
+ ```ruby
360
+ {
361
+ "content" => {
362
+ "path" => "tmp/hello.mp3",
363
+ "bytes" => 12345,
364
+ "format" => "mp3"
365
+ },
366
+ "response_id" => nil,
367
+ "status" => 200,
368
+ "error" => nil,
369
+ "raw" => nil
370
+ }
371
+ ```
372
+
373
+ Use `base64: true` when you want text-safe audio data that can be transported in JSON and decoded later:
374
+
375
+ ```ruby
376
+ result = ai.speak("Hello from AI Lite", base64: true)
377
+
378
+ File.binwrite("tmp/hello.mp3", Base64.decode64(result["content"]))
379
+ ```
380
+
381
+ `speak` sends a `POST` request to `/v1/audio/speech` with:
382
+
383
+ - `model`
384
+ - `input`
385
+ - `voice`
386
+ - optional `response_format`
387
+ - optional `speed`
388
+ - optional `instructions`
389
+ - optional `debug`
390
+ - optional extra `options`
391
+
392
+ The default speech model is `gpt-4o-mini-tts`.
393
+ The default speech voice is `alloy`.
394
+ The default response format is `mp3`.
395
+
396
+ Set `voice` per call when you want a different built-in voice:
397
+
398
+ ```ruby
399
+ result = ai.speak(
400
+ "Hello from AI Lite",
401
+ voice: "sage",
402
+ output_path: "tmp/hello.mp3"
403
+ )
404
+ ```
405
+
406
+ Use `response_format` to request `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm` output:
407
+
408
+ ```ruby
409
+ result = ai.speak(
410
+ "Export this as a WAV file",
411
+ response_format: "wav",
412
+ output_path: "tmp/hello.wav"
413
+ )
257
414
  ```
258
415
 
259
416
  ## Return Shape
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.3.0".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -9,6 +9,10 @@ class AiLite
9
9
  DEFAULT_MODEL = "gpt-5.5".freeze
10
10
  DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
11
  DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
12
+ DEFAULT_IMAGE_MODEL = "gpt-image-2".freeze
13
+ DEFAULT_SPEECH_MODEL = "gpt-4o-mini-tts".freeze
14
+ DEFAULT_SPEECH_VOICE = "alloy".freeze
15
+ DEFAULT_SPEECH_FORMAT = "mp3".freeze
12
16
  DEFAULT_TIMEOUT = 120
13
17
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
14
18
  IMAGE_MIME_TYPES = {
@@ -20,13 +24,16 @@ class AiLite
20
24
  }.freeze
21
25
 
22
26
  class Configuration
23
- attr_accessor :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens
27
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :timeout, :max_output_tokens
24
28
 
25
29
  def initialize
26
30
  @api_key = nil
27
31
  @model = DEFAULT_MODEL
28
32
  @moderation_model = DEFAULT_MODERATION_MODEL
29
33
  @embedding_model = DEFAULT_EMBEDDING_MODEL
34
+ @image_model = DEFAULT_IMAGE_MODEL
35
+ @speech_model = DEFAULT_SPEECH_MODEL
36
+ @speech_voice = DEFAULT_SPEECH_VOICE
30
37
  @timeout = DEFAULT_TIMEOUT
31
38
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
32
39
  end
@@ -65,20 +72,31 @@ class AiLite
65
72
  client.embed(input, **kwargs)
66
73
  end
67
74
 
75
+ def image(prompt, **kwargs)
76
+ client.image(prompt, **kwargs)
77
+ end
78
+
79
+ def speak(text, **kwargs)
80
+ client.speak(text, **kwargs)
81
+ end
82
+
68
83
  def reset_client!
69
84
  @client = nil
70
85
  end
71
86
  end
72
87
 
73
- attr_reader :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens, :headers
88
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :timeout, :max_output_tokens, :headers
74
89
 
75
- def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, timeout: nil, max_output_tokens: nil)
90
+ def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, speech_model: nil, speech_voice: nil, timeout: nil, max_output_tokens: nil)
76
91
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
77
92
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
78
93
 
79
94
  @model = model || self.class.configuration.model
80
95
  @moderation_model = moderation_model || self.class.configuration.moderation_model
81
96
  @embedding_model = embedding_model || self.class.configuration.embedding_model
97
+ @image_model = image_model || self.class.configuration.image_model
98
+ @speech_model = speech_model || self.class.configuration.speech_model
99
+ @speech_voice = speech_voice || self.class.configuration.speech_voice
82
100
  @timeout = timeout || self.class.configuration.timeout
83
101
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
84
102
  @headers = {
@@ -124,6 +142,42 @@ class AiLite
124
142
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
125
143
  end
126
144
 
145
+ def image(prompt, model: nil, size: nil, quality: nil, background: nil, output_format: nil, output_path: nil, debug: false, options: {})
146
+ payload = options.merge(
147
+ model: model || image_model,
148
+ prompt: prompt
149
+ )
150
+ payload[:size] = size if size
151
+ payload[:quality] = quality if quality
152
+ payload[:background] = background if background
153
+ payload[:output_format] = output_format if output_format
154
+
155
+ extract_image(post(payload, endpoint: image_endpoint), output_path: output_path, debug: debug)
156
+ rescue => e
157
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
158
+ end
159
+
160
+ def speak(text, model: nil, voice: nil, response_format: nil, speed: nil, instructions: nil, output_path: nil, base64: false, debug: false, options: {})
161
+ payload = options.merge(
162
+ model: model || speech_model,
163
+ input: text,
164
+ voice: voice || speech_voice
165
+ )
166
+ payload[:response_format] = response_format if response_format
167
+ payload[:speed] = speed if speed
168
+ payload[:instructions] = instructions if instructions
169
+
170
+ extract_speech(
171
+ post(payload, endpoint: speech_endpoint),
172
+ output_path: output_path,
173
+ base64: base64,
174
+ response_format: payload[:response_format] || payload["response_format"] || DEFAULT_SPEECH_FORMAT,
175
+ debug: debug
176
+ )
177
+ rescue => e
178
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
179
+ end
180
+
127
181
  private
128
182
 
129
183
  def post(payload, endpoint: response_endpoint)
@@ -155,6 +209,14 @@ class AiLite
155
209
  "#{API_BASE_URL}/embeddings"
156
210
  end
157
211
 
212
+ def image_endpoint
213
+ "#{API_BASE_URL}/images/generations"
214
+ end
215
+
216
+ def speech_endpoint
217
+ "#{API_BASE_URL}/audio/speech"
218
+ end
219
+
158
220
  def extract_content(response, debug: false)
159
221
  status = response.code.to_i
160
222
  parsed_response = JSON.parse(response.body)
@@ -238,6 +300,65 @@ class AiLite
238
300
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
239
301
  end
240
302
 
303
+ def extract_image(response, output_path:, debug: false)
304
+ status = response.code.to_i
305
+ parsed_response = JSON.parse(response.body)
306
+
307
+ unless success_status?(status)
308
+ return prettify_data(
309
+ status: status,
310
+ error: error_message(parsed_response),
311
+ response_id: parsed_response["id"],
312
+ raw: parsed_response,
313
+ debug: debug
314
+ )
315
+ end
316
+
317
+ content = image_content(parsed_response)
318
+ write_image_output(output_path, content) if output_path
319
+
320
+ prettify_data(
321
+ status: status,
322
+ content: content,
323
+ response_id: parsed_response["id"],
324
+ raw: parsed_response,
325
+ debug: debug
326
+ )
327
+ rescue JSON::ParserError => e
328
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
329
+ rescue => e
330
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
331
+ end
332
+
333
+ def extract_speech(response, output_path:, base64:, response_format:, debug: false)
334
+ status = response.code.to_i
335
+
336
+ unless success_status?(status)
337
+ parsed_response = parse_error_response(response.body)
338
+
339
+ return prettify_data(
340
+ status: status,
341
+ error: error_message(parsed_response),
342
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
343
+ raw: parsed_response,
344
+ debug: debug
345
+ )
346
+ end
347
+
348
+ audio = response.body
349
+ File.binwrite(output_path, audio) if output_path
350
+
351
+ prettify_data(
352
+ status: status,
353
+ content: speech_content(audio, output_path: output_path, base64: base64, response_format: response_format),
354
+ response_id: nil,
355
+ raw: audio,
356
+ debug: debug
357
+ )
358
+ rescue => e
359
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
360
+ end
361
+
241
362
  def extract_output_text(raw)
242
363
  Array(raw["output"]).flat_map do |item|
243
364
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -312,6 +433,34 @@ class AiLite
312
433
  multiple ? embeddings : embeddings.first
313
434
  end
314
435
 
436
+ def image_content(raw)
437
+ image = Array(raw["data"]).find { |item| item.is_a?(Hash) && item["b64_json"] }
438
+ image && image["b64_json"]
439
+ end
440
+
441
+ def write_image_output(path, content)
442
+ raise "No image data returned" if content.to_s.empty?
443
+
444
+ File.binwrite(path, Base64.decode64(content))
445
+ end
446
+
447
+ def speech_content(audio, output_path:, base64:, response_format:)
448
+ return Base64.strict_encode64(audio) if base64
449
+ return audio unless output_path
450
+
451
+ {
452
+ "path" => output_path,
453
+ "bytes" => audio.bytesize,
454
+ "format" => response_format
455
+ }
456
+ end
457
+
458
+ def parse_error_response(body)
459
+ JSON.parse(body)
460
+ rescue JSON::ParserError
461
+ body
462
+ end
463
+
315
464
  def success_status?(status)
316
465
  status >= 200 && status < 300
317
466
  end
data/test/ai_lite_test.rb CHANGED
@@ -33,6 +33,9 @@ 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",
37
+ speech_model: "gpt-speech-test",
38
+ speech_voice: "verse",
36
39
  timeout: 10
37
40
  )
38
41
 
@@ -40,6 +43,9 @@ class AiLiteTest < Minitest::Test
40
43
  assert_equal "gpt-test", client.model
41
44
  assert_equal "omni-moderation-latest", client.moderation_model
42
45
  assert_equal "text-embedding-3-small", client.embedding_model
46
+ assert_equal "gpt-image-test", client.image_model
47
+ assert_equal "gpt-speech-test", client.speech_model
48
+ assert_equal "verse", client.speech_voice
43
49
  assert_equal 10, client.timeout
44
50
  assert_equal 2000, client.max_output_tokens
45
51
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -54,6 +60,9 @@ class AiLiteTest < Minitest::Test
54
60
  config.model = "gpt-config"
55
61
  config.moderation_model = "omni-moderation-test"
56
62
  config.embedding_model = "text-embedding-test"
63
+ config.image_model = "gpt-image-test"
64
+ config.speech_model = "gpt-speech-test"
65
+ config.speech_voice = "verse"
57
66
  config.timeout = 15
58
67
  config.max_output_tokens = 750
59
68
  end
@@ -64,6 +73,9 @@ class AiLiteTest < Minitest::Test
64
73
  assert_equal "gpt-config", client.model
65
74
  assert_equal "omni-moderation-test", client.moderation_model
66
75
  assert_equal "text-embedding-test", client.embedding_model
76
+ assert_equal "gpt-image-test", client.image_model
77
+ assert_equal "gpt-speech-test", client.speech_model
78
+ assert_equal "verse", client.speech_voice
67
79
  assert_equal 15, client.timeout
68
80
  assert_equal 750, client.max_output_tokens
69
81
  assert_same client, AiLite.client
@@ -87,6 +99,9 @@ class AiLiteTest < Minitest::Test
87
99
  config.model = "gpt-config"
88
100
  config.moderation_model = "omni-moderation-config"
89
101
  config.embedding_model = "text-embedding-config"
102
+ config.image_model = "gpt-image-config"
103
+ config.speech_model = "gpt-speech-config"
104
+ config.speech_voice = "sage"
90
105
  config.timeout = 15
91
106
  config.max_output_tokens = 750
92
107
  end
@@ -96,6 +111,9 @@ class AiLiteTest < Minitest::Test
96
111
  model: "gpt-explicit",
97
112
  moderation_model: "omni-moderation-explicit",
98
113
  embedding_model: "text-embedding-explicit",
114
+ image_model: "gpt-image-explicit",
115
+ speech_model: "gpt-speech-explicit",
116
+ speech_voice: "coral",
99
117
  timeout: 5,
100
118
  max_output_tokens: 300
101
119
  )
@@ -104,6 +122,9 @@ class AiLiteTest < Minitest::Test
104
122
  assert_equal "gpt-explicit", client.model
105
123
  assert_equal "omni-moderation-explicit", client.moderation_model
106
124
  assert_equal "text-embedding-explicit", client.embedding_model
125
+ assert_equal "gpt-image-explicit", client.image_model
126
+ assert_equal "gpt-speech-explicit", client.speech_model
127
+ assert_equal "coral", client.speech_voice
107
128
  assert_equal 5, client.timeout
108
129
  assert_equal 300, client.max_output_tokens
109
130
  end
@@ -525,6 +546,245 @@ class AiLiteTest < Minitest::Test
525
546
  end
526
547
  end
527
548
 
549
+ def test_image_sends_post_to_images_with_default_payload
550
+ client = AiLite.new(api_key: "token-abc")
551
+ image_data = Base64.strict_encode64("fake image")
552
+
553
+ with_stubbed_http(image_response(b64_json: image_data)) do |captured, _response|
554
+ result = client.image("A small ruby gem logo")
555
+ request = captured[:http].last_request
556
+ payload = JSON.parse(request.body)
557
+
558
+ assert_equal image_data, result["content"]
559
+ assert_nil result["response_id"]
560
+ assert_equal 200, result["status"]
561
+ assert_nil result["error"]
562
+ assert_nil result["raw"]
563
+ assert_equal "api.openai.com", captured[:host]
564
+ assert_equal 443, captured[:port]
565
+ assert_equal true, captured[:use_ssl]
566
+ assert_instance_of Net::HTTP::Post, request
567
+ assert_equal "/v1/images/generations", request.path
568
+ assert_equal "Bearer token-abc", request["Authorization"]
569
+ assert_equal "application/json", request["Content-Type"]
570
+ assert_equal "gpt-image-2", payload["model"]
571
+ assert_equal "A small ruby gem logo", payload["prompt"]
572
+ end
573
+ end
574
+
575
+ def test_image_includes_options_size_quality_background_output_format_and_model
576
+ client = AiLite.new(api_key: "token-abc")
577
+
578
+ with_stubbed_http(image_response) do |captured, _response|
579
+ client.image(
580
+ "A transparent app icon",
581
+ model: "gpt-image-test",
582
+ size: "1024x1536",
583
+ quality: "high",
584
+ background: "transparent",
585
+ output_format: "webp",
586
+ options: {
587
+ moderation: "auto",
588
+ output_compression: 80
589
+ }
590
+ )
591
+ payload = JSON.parse(captured[:http].last_request.body)
592
+
593
+ assert_equal "gpt-image-test", payload["model"]
594
+ assert_equal "A transparent app icon", payload["prompt"]
595
+ assert_equal "1024x1536", payload["size"]
596
+ assert_equal "high", payload["quality"]
597
+ assert_equal "transparent", payload["background"]
598
+ assert_equal "webp", payload["output_format"]
599
+ assert_equal "auto", payload["moderation"]
600
+ assert_equal 80, payload["output_compression"]
601
+ end
602
+ end
603
+
604
+ def test_image_uses_class_level_configured_client
605
+ AiLite.configure do |config|
606
+ config.api_key = "configured-key"
607
+ config.image_model = "gpt-image-config"
608
+ end
609
+
610
+ with_stubbed_http(image_response) do |captured, _response|
611
+ AiLite.image("Use configured defaults")
612
+ payload = JSON.parse(captured[:http].last_request.body)
613
+
614
+ assert_equal "gpt-image-config", payload["model"]
615
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
616
+ end
617
+ end
618
+
619
+ def test_image_writes_decoded_content_to_output_path
620
+ client = AiLite.new(api_key: "token-abc")
621
+ image_data = Base64.strict_encode64("fake image")
622
+
623
+ Tempfile.create(["generated", ".png"]) do |file|
624
+ with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
625
+ result = client.image("A file output", output_path: file.path)
626
+
627
+ assert_equal image_data, result["content"]
628
+ assert_equal "fake image", File.binread(file.path)
629
+ end
630
+ end
631
+ end
632
+
633
+ def test_image_debug_true_returns_raw_usage
634
+ client = AiLite.new(api_key: "token-abc")
635
+ image_data = Base64.strict_encode64("fake image")
636
+
637
+ with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
638
+ result = client.image("A debuggable image", debug: true)
639
+
640
+ assert_equal image_data, result["content"]
641
+ assert_equal({ "input_tokens" => 10, "output_tokens" => 20, "total_tokens" => 30 }, result["raw"]["usage"])
642
+ end
643
+ end
644
+
645
+ def test_image_output_path_without_image_data_returns_standard_envelope
646
+ client = AiLite.new(api_key: "token-abc")
647
+
648
+ Tempfile.create(["generated", ".png"]) do |file|
649
+ with_stubbed_http(image_response(b64_json: nil)) do |_captured, _response|
650
+ result = client.image("A missing image", output_path: file.path)
651
+
652
+ assert_nil result["content"]
653
+ assert_nil result["response_id"]
654
+ assert_equal 200, result["status"]
655
+ assert_equal "No image data returned", result["error"]
656
+ assert_nil result["raw"]
657
+ end
658
+ end
659
+ end
660
+
661
+ def test_speak_sends_post_to_audio_speech_with_default_payload
662
+ client = AiLite.new(api_key: "token-abc")
663
+
664
+ with_stubbed_http(speech_response("fake audio")) do |captured, _response|
665
+ result = client.speak("Read this aloud")
666
+ request = captured[:http].last_request
667
+ payload = JSON.parse(request.body)
668
+
669
+ assert_equal "fake audio", result["content"]
670
+ assert_nil result["response_id"]
671
+ assert_equal 200, result["status"]
672
+ assert_nil result["error"]
673
+ assert_nil result["raw"]
674
+ assert_equal "api.openai.com", captured[:host]
675
+ assert_equal 443, captured[:port]
676
+ assert_equal true, captured[:use_ssl]
677
+ assert_instance_of Net::HTTP::Post, request
678
+ assert_equal "/v1/audio/speech", request.path
679
+ assert_equal "Bearer token-abc", request["Authorization"]
680
+ assert_equal "application/json", request["Content-Type"]
681
+ assert_equal "gpt-4o-mini-tts", payload["model"]
682
+ assert_equal "Read this aloud", payload["input"]
683
+ assert_equal "alloy", payload["voice"]
684
+ end
685
+ end
686
+
687
+ def test_speak_includes_options_response_format_speed_instructions_model_and_voice
688
+ client = AiLite.new(api_key: "token-abc")
689
+
690
+ with_stubbed_http(speech_response) do |captured, _response|
691
+ client.speak(
692
+ "Use a clear support tone",
693
+ model: "gpt-speech-test",
694
+ voice: "sage",
695
+ response_format: "wav",
696
+ speed: 1.2,
697
+ instructions: "Speak warmly.",
698
+ options: {
699
+ stream_format: "audio"
700
+ }
701
+ )
702
+ payload = JSON.parse(captured[:http].last_request.body)
703
+
704
+ assert_equal "gpt-speech-test", payload["model"]
705
+ assert_equal "Use a clear support tone", payload["input"]
706
+ assert_equal "sage", payload["voice"]
707
+ assert_equal "wav", payload["response_format"]
708
+ assert_equal 1.2, payload["speed"]
709
+ assert_equal "Speak warmly.", payload["instructions"]
710
+ assert_equal "audio", payload["stream_format"]
711
+ end
712
+ end
713
+
714
+ def test_speak_uses_class_level_configured_client
715
+ AiLite.configure do |config|
716
+ config.api_key = "configured-key"
717
+ config.speech_model = "gpt-speech-config"
718
+ config.speech_voice = "marin"
719
+ end
720
+
721
+ with_stubbed_http(speech_response) do |captured, _response|
722
+ AiLite.speak("Use configured defaults")
723
+ payload = JSON.parse(captured[:http].last_request.body)
724
+
725
+ assert_equal "gpt-speech-config", payload["model"]
726
+ assert_equal "marin", payload["voice"]
727
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
728
+ end
729
+ end
730
+
731
+ def test_speak_writes_audio_to_output_path_and_returns_metadata
732
+ client = AiLite.new(api_key: "token-abc")
733
+
734
+ Tempfile.create(["speech", ".mp3"]) do |file|
735
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
736
+ result = client.speak("Save this", output_path: file.path)
737
+
738
+ assert_equal(
739
+ {
740
+ "path" => file.path,
741
+ "bytes" => 10,
742
+ "format" => "mp3"
743
+ },
744
+ result["content"]
745
+ )
746
+ assert_equal "fake audio", File.binread(file.path)
747
+ end
748
+ end
749
+ end
750
+
751
+ def test_speak_base64_true_returns_encoded_audio
752
+ client = AiLite.new(api_key: "token-abc")
753
+
754
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
755
+ result = client.speak("Encode this", base64: true)
756
+
757
+ assert_equal Base64.strict_encode64("fake audio"), result["content"]
758
+ assert_nil result["raw"]
759
+ end
760
+ end
761
+
762
+ def test_speak_debug_true_returns_raw_audio
763
+ client = AiLite.new(api_key: "token-abc")
764
+
765
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
766
+ result = client.speak("Debug this", debug: true)
767
+
768
+ assert_equal "fake audio", result["content"]
769
+ assert_equal "fake audio", result["raw"]
770
+ end
771
+ end
772
+
773
+ def test_speak_http_errors_return_standard_envelope
774
+ client = AiLite.new(api_key: "token-abc")
775
+ body = JSON.generate("error" => { "message" => "Unsupported voice" })
776
+
777
+ with_stubbed_http(FakeResponse.new("400", body)) do |_captured, _response|
778
+ result = client.speak("Say hello")
779
+
780
+ assert_nil result["content"]
781
+ assert_nil result["response_id"]
782
+ assert_equal 400, result["status"]
783
+ assert_equal "Unsupported voice", result["error"]
784
+ assert_nil result["raw"]
785
+ end
786
+ end
787
+
528
788
  def test_http_errors_return_standard_envelope
529
789
  client = AiLite.new(api_key: "token-abc")
530
790
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -659,6 +919,30 @@ class AiLiteTest < Minitest::Test
659
919
  FakeResponse.new("200", body)
660
920
  end
661
921
 
922
+ def image_response(b64_json: Base64.strict_encode64("fake image"))
923
+ image = {}
924
+ image["b64_json"] = b64_json if b64_json
925
+
926
+ body = JSON.generate(
927
+ "created" => 1_713_833_628,
928
+ "background" => "opaque",
929
+ "data" => [image],
930
+ "output_format" => "png",
931
+ "quality" => "medium",
932
+ "size" => "1024x1024",
933
+ "usage" => {
934
+ "input_tokens" => 10,
935
+ "output_tokens" => 20,
936
+ "total_tokens" => 30
937
+ }
938
+ )
939
+ FakeResponse.new("200", body)
940
+ end
941
+
942
+ def speech_response(audio = "fake audio")
943
+ FakeResponse.new("200", audio)
944
+ end
945
+
662
946
  def with_env(values)
663
947
  originals = {}
664
948
 
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.3.0
4
+ version: 0.5.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-07-07 00:00:00.000000000 Z
11
+ date: 2026-08-24 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.