ai-lite 0.4.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: 4f75f42caf4d74f3abf2ec982b7c3830c715b1d1c54f2ae0fdf02a1dec5151db
4
- data.tar.gz: ff953a4533862067eee149c04ac5f348bf3b97e7e9f7b87fbcd18a3fe3d63233
3
+ metadata.gz: cb52e45732e37e363b41144d47cc4136dab1feb450890f10cf84f7cc15593b2a
4
+ data.tar.gz: 2a05669e6fccdf8da0d1a3a68dc21598806e6072de2fda487e9b436751add386
5
5
  SHA512:
6
- metadata.gz: c2c91c198575156c15e0100c3fa12709b1cf09395785d4a634e46bf06e9bc9dbf9e8073a95e990f54c4e19a0b72322379317299443899d83eef5c587864b6781
7
- data.tar.gz: c528951faf3fe8f453def5a8dbaf7eb00c7da77fc5135c22ccfe2ac5765a2603a0c5cf50dcdfcbe1dda7aaedf6f69ca210a19424b67ef2866c627cd40e17104e
6
+ metadata.gz: 307ffdbea749aac0eeca74b678d8af55a96add4924e1ef5e5f9157a83138b0929adc2db3282d79a924a24929d8c532fe1742f8ecb801409cf4d5c38fff3639ee
7
+ data.tar.gz: a50f9ef6171f3ee7249fc46d02e77698820272df70fe3ddbef777f6ab3159879c92c6d6d5fd7eddf29ceda3387308480a2622f95772b85b21fdbc2037808eda7
data/README.md CHANGED
@@ -20,6 +20,7 @@ ai.chat("Say hello")
20
20
  ai.moderate("User submitted text")
21
21
  ai.embed("Text to vectorize")
22
22
  ai.image("A simple app icon")
23
+ ai.speak("Read this aloud")
23
24
  ```
24
25
 
25
26
  ## Usage
@@ -53,6 +54,8 @@ AiLite.configure do |config|
53
54
  config.moderation_model = "omni-moderation-latest"
54
55
  config.embedding_model = "text-embedding-3-small"
55
56
  config.image_model = "gpt-image-2"
57
+ config.speech_model = "gpt-4o-mini-tts"
58
+ config.speech_voice = "alloy"
56
59
  config.timeout = 120
57
60
  config.max_output_tokens = 2000
58
61
  end
@@ -97,6 +100,23 @@ The default model is `gpt-5.5`.
97
100
 
98
101
  The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
99
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
+
100
120
  ## Moderation
101
121
 
102
122
  Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
@@ -304,21 +324,93 @@ result["content"] # base64 image data
304
324
  result["raw"]["usage"] # token usage, when returned
305
325
  ```
306
326
 
307
- ## Multi-Turn Chat
327
+ ## Speech
308
328
 
309
- Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
329
+ Use `speak` to generate audio from text.
310
330
 
311
331
  ```ruby
312
- first = ai.chat("Tell me a short joke.")
332
+ result = ai.speak("Hello from AI Lite")
333
+ audio_bytes = result["content"]
334
+ ```
313
335
 
314
- follow_up = ai.chat(
315
- "Explain why that is funny.",
316
- options: {
317
- previous_response_id: first["response_id"]
318
- }
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"
319
354
  )
355
+ ```
320
356
 
321
- puts follow_up["content"]
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
+ )
322
414
  ```
323
415
 
324
416
  ## Return Shape
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.4.0".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -10,6 +10,9 @@ class AiLite
10
10
  DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
11
  DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
12
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
13
16
  DEFAULT_TIMEOUT = 120
14
17
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
15
18
  IMAGE_MIME_TYPES = {
@@ -21,7 +24,7 @@ class AiLite
21
24
  }.freeze
22
25
 
23
26
  class Configuration
24
- attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_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
25
28
 
26
29
  def initialize
27
30
  @api_key = nil
@@ -29,6 +32,8 @@ class AiLite
29
32
  @moderation_model = DEFAULT_MODERATION_MODEL
30
33
  @embedding_model = DEFAULT_EMBEDDING_MODEL
31
34
  @image_model = DEFAULT_IMAGE_MODEL
35
+ @speech_model = DEFAULT_SPEECH_MODEL
36
+ @speech_voice = DEFAULT_SPEECH_VOICE
32
37
  @timeout = DEFAULT_TIMEOUT
33
38
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
34
39
  end
@@ -71,14 +76,18 @@ class AiLite
71
76
  client.image(prompt, **kwargs)
72
77
  end
73
78
 
79
+ def speak(text, **kwargs)
80
+ client.speak(text, **kwargs)
81
+ end
82
+
74
83
  def reset_client!
75
84
  @client = nil
76
85
  end
77
86
  end
78
87
 
79
- attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_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
80
89
 
81
- def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_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)
82
91
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
83
92
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
84
93
 
@@ -86,6 +95,8 @@ class AiLite
86
95
  @moderation_model = moderation_model || self.class.configuration.moderation_model
87
96
  @embedding_model = embedding_model || self.class.configuration.embedding_model
88
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
89
100
  @timeout = timeout || self.class.configuration.timeout
90
101
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
91
102
  @headers = {
@@ -146,6 +157,27 @@ class AiLite
146
157
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
147
158
  end
148
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
+
149
181
  private
150
182
 
151
183
  def post(payload, endpoint: response_endpoint)
@@ -181,6 +213,10 @@ class AiLite
181
213
  "#{API_BASE_URL}/images/generations"
182
214
  end
183
215
 
216
+ def speech_endpoint
217
+ "#{API_BASE_URL}/audio/speech"
218
+ end
219
+
184
220
  def extract_content(response, debug: false)
185
221
  status = response.code.to_i
186
222
  parsed_response = JSON.parse(response.body)
@@ -294,6 +330,35 @@ class AiLite
294
330
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
295
331
  end
296
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
+
297
362
  def extract_output_text(raw)
298
363
  Array(raw["output"]).flat_map do |item|
299
364
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -379,6 +444,23 @@ class AiLite
379
444
  File.binwrite(path, Base64.decode64(content))
380
445
  end
381
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
+
382
464
  def success_status?(status)
383
465
  status >= 200 && status < 300
384
466
  end
data/test/ai_lite_test.rb CHANGED
@@ -34,6 +34,8 @@ class AiLiteTest < Minitest::Test
34
34
  api_key: "explicit-key",
35
35
  model: "gpt-test",
36
36
  image_model: "gpt-image-test",
37
+ speech_model: "gpt-speech-test",
38
+ speech_voice: "verse",
37
39
  timeout: 10
38
40
  )
39
41
 
@@ -42,6 +44,8 @@ class AiLiteTest < Minitest::Test
42
44
  assert_equal "omni-moderation-latest", client.moderation_model
43
45
  assert_equal "text-embedding-3-small", client.embedding_model
44
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
45
49
  assert_equal 10, client.timeout
46
50
  assert_equal 2000, client.max_output_tokens
47
51
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -57,6 +61,8 @@ class AiLiteTest < Minitest::Test
57
61
  config.moderation_model = "omni-moderation-test"
58
62
  config.embedding_model = "text-embedding-test"
59
63
  config.image_model = "gpt-image-test"
64
+ config.speech_model = "gpt-speech-test"
65
+ config.speech_voice = "verse"
60
66
  config.timeout = 15
61
67
  config.max_output_tokens = 750
62
68
  end
@@ -68,6 +74,8 @@ class AiLiteTest < Minitest::Test
68
74
  assert_equal "omni-moderation-test", client.moderation_model
69
75
  assert_equal "text-embedding-test", client.embedding_model
70
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
71
79
  assert_equal 15, client.timeout
72
80
  assert_equal 750, client.max_output_tokens
73
81
  assert_same client, AiLite.client
@@ -92,6 +100,8 @@ class AiLiteTest < Minitest::Test
92
100
  config.moderation_model = "omni-moderation-config"
93
101
  config.embedding_model = "text-embedding-config"
94
102
  config.image_model = "gpt-image-config"
103
+ config.speech_model = "gpt-speech-config"
104
+ config.speech_voice = "sage"
95
105
  config.timeout = 15
96
106
  config.max_output_tokens = 750
97
107
  end
@@ -102,6 +112,8 @@ class AiLiteTest < Minitest::Test
102
112
  moderation_model: "omni-moderation-explicit",
103
113
  embedding_model: "text-embedding-explicit",
104
114
  image_model: "gpt-image-explicit",
115
+ speech_model: "gpt-speech-explicit",
116
+ speech_voice: "coral",
105
117
  timeout: 5,
106
118
  max_output_tokens: 300
107
119
  )
@@ -111,6 +123,8 @@ class AiLiteTest < Minitest::Test
111
123
  assert_equal "omni-moderation-explicit", client.moderation_model
112
124
  assert_equal "text-embedding-explicit", client.embedding_model
113
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
114
128
  assert_equal 5, client.timeout
115
129
  assert_equal 300, client.max_output_tokens
116
130
  end
@@ -644,6 +658,133 @@ class AiLiteTest < Minitest::Test
644
658
  end
645
659
  end
646
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
+
647
788
  def test_http_errors_return_standard_envelope
648
789
  client = AiLite.new(api_key: "token-abc")
649
790
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -798,6 +939,10 @@ class AiLiteTest < Minitest::Test
798
939
  FakeResponse.new("200", body)
799
940
  end
800
941
 
942
+ def speech_response(audio = "fake audio")
943
+ FakeResponse.new("200", audio)
944
+ end
945
+
801
946
  def with_env(values)
802
947
  originals = {}
803
948
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - William Basmayor
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-04 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.