ai-lite 0.4.0 → 0.6.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: 2866b9f2b18c63bf07b51f6193b421bbb5fa8afc435ee8a50ce16d4debef9d04
4
+ data.tar.gz: d6bf73b903f387f2221b5d1c39e06de05901a7b3074161e59c773e649ccf4d6f
5
5
  SHA512:
6
- metadata.gz: c2c91c198575156c15e0100c3fa12709b1cf09395785d4a634e46bf06e9bc9dbf9e8073a95e990f54c4e19a0b72322379317299443899d83eef5c587864b6781
7
- data.tar.gz: c528951faf3fe8f453def5a8dbaf7eb00c7da77fc5135c22ccfe2ac5765a2603a0c5cf50dcdfcbe1dda7aaedf6f69ca210a19424b67ef2866c627cd40e17104e
6
+ metadata.gz: d26f6651189db9ad98ec5e5229808a8ede9cc1cb363ec66b5ade079fc1d23c14686ebed11047422b2c2882b9c1916352726385a4cac6f7ae539d510a3abd8923
7
+ data.tar.gz: f5a3aef1e3312999d53ac57e0941b8fa34225b7b84e504fb7564293d7c60542bd06703bb029a032ecc9bdbcd5e7c3251287f470e146c589f0fec26dc15a199f9
data/README.md CHANGED
@@ -11,7 +11,7 @@ This gem is intentionally small:
11
11
  - No Rails dependency
12
12
  - No official OpenAI gem dependency
13
13
  - No Faraday, HTTParty, ActiveSupport, or connection pool dependency
14
- - Uses only Ruby stdlib: `Net::HTTP`, `URI`, `JSON`, and `Base64`
14
+ - Uses only Ruby stdlib: `Net::HTTP`, `URI`, `JSON`, `Base64`, and `SecureRandom`
15
15
 
16
16
  It is not meant to replace the official OpenAI SDK. It is a small wrapper for projects that only need a few clean interfaces:
17
17
 
@@ -20,6 +20,8 @@ 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")
24
+ ai.transcribe("tmp/meeting.mp3")
23
25
  ```
24
26
 
25
27
  ## Usage
@@ -53,11 +55,28 @@ AiLite.configure do |config|
53
55
  config.moderation_model = "omni-moderation-latest"
54
56
  config.embedding_model = "text-embedding-3-small"
55
57
  config.image_model = "gpt-image-2"
58
+ config.speech_model = "gpt-4o-mini-tts"
59
+ config.speech_voice = "alloy"
60
+ config.transcription_model = "gpt-transcribe"
56
61
  config.timeout = 120
57
62
  config.max_output_tokens = 2000
58
63
  end
59
64
  ```
60
65
 
66
+ Any value left unset falls back to AI Lite's default:
67
+
68
+ - `model`: `gpt-5.5`
69
+ - `moderation_model`: `omni-moderation-latest`
70
+ - `embedding_model`: `text-embedding-3-small`
71
+ - `image_model`: `gpt-image-2`
72
+ - `speech_model`: `gpt-4o-mini-tts`
73
+ - `speech_voice`: `alloy`
74
+ - `transcription_model`: `gpt-transcribe`
75
+ - `timeout`: `120`
76
+ - `max_output_tokens`: `2000`
77
+
78
+ `timeout` is applied to both the HTTP connection timeout and the HTTP read timeout.
79
+
61
80
  Then use the configured singleton-style client:
62
81
 
63
82
  ```ruby
@@ -97,6 +116,23 @@ The default model is `gpt-5.5`.
97
116
 
98
117
  The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
99
118
 
119
+ ### Multi-Turn Chat
120
+
121
+ Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
122
+
123
+ ```ruby
124
+ first = ai.chat("Tell me a short joke.")
125
+
126
+ follow_up = ai.chat(
127
+ "Explain why that is funny.",
128
+ options: {
129
+ previous_response_id: first["response_id"]
130
+ }
131
+ )
132
+
133
+ puts follow_up["content"]
134
+ ```
135
+
100
136
  ## Moderation
101
137
 
102
138
  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 +340,158 @@ result["content"] # base64 image data
304
340
  result["raw"]["usage"] # token usage, when returned
305
341
  ```
306
342
 
307
- ## Multi-Turn Chat
343
+ ## Speech
308
344
 
309
- Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
345
+ Use `speak` to generate audio from text.
310
346
 
311
347
  ```ruby
312
- first = ai.chat("Tell me a short joke.")
348
+ result = ai.speak("Hello from AI Lite")
349
+ audio_bytes = result["content"]
350
+ ```
313
351
 
314
- follow_up = ai.chat(
315
- "Explain why that is funny.",
316
- options: {
317
- previous_response_id: first["response_id"]
318
- }
352
+ By default, `content` is the raw audio bytes returned by OpenAI:
353
+
354
+ ```ruby
355
+ {
356
+ "content" => "...binary audio bytes...",
357
+ "response_id" => nil,
358
+ "status" => 200,
359
+ "error" => nil,
360
+ "raw" => nil
361
+ }
362
+ ```
363
+
364
+ Write the generated audio directly to a file with `output_path`:
365
+
366
+ ```ruby
367
+ result = ai.speak(
368
+ "Hello from AI Lite",
369
+ output_path: "tmp/hello.mp3"
319
370
  )
371
+ ```
320
372
 
321
- puts follow_up["content"]
373
+ When `output_path` is used, `content` is file metadata:
374
+
375
+ ```ruby
376
+ {
377
+ "content" => {
378
+ "path" => "tmp/hello.mp3",
379
+ "bytes" => 12345,
380
+ "format" => "mp3"
381
+ },
382
+ "response_id" => nil,
383
+ "status" => 200,
384
+ "error" => nil,
385
+ "raw" => nil
386
+ }
387
+ ```
388
+
389
+ Use `base64: true` when you want text-safe audio data that can be transported in JSON and decoded later:
390
+
391
+ ```ruby
392
+ result = ai.speak("Hello from AI Lite", base64: true)
393
+
394
+ File.binwrite("tmp/hello.mp3", Base64.decode64(result["content"]))
395
+ ```
396
+
397
+ `speak` sends a `POST` request to `/v1/audio/speech` with:
398
+
399
+ - `model`
400
+ - `input`
401
+ - `voice`
402
+ - optional `response_format`
403
+ - optional `speed`
404
+ - optional `instructions`
405
+ - optional `debug`
406
+ - optional extra `options`
407
+
408
+ The default speech model is `gpt-4o-mini-tts`.
409
+ The default speech voice is `alloy`.
410
+ The default response format is `mp3`.
411
+
412
+ Set `voice` per call when you want a different built-in voice:
413
+
414
+ ```ruby
415
+ result = ai.speak(
416
+ "Hello from AI Lite",
417
+ voice: "sage",
418
+ output_path: "tmp/hello.mp3"
419
+ )
420
+ ```
421
+
422
+ Use `response_format` to request `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm` output:
423
+
424
+ ```ruby
425
+ result = ai.speak(
426
+ "Export this as a WAV file",
427
+ response_format: "wav",
428
+ output_path: "tmp/hello.wav"
429
+ )
430
+ ```
431
+
432
+ ## Transcription
433
+
434
+ Use `transcribe` to turn an audio file into text.
435
+
436
+ ```ruby
437
+ result = ai.transcribe("tmp/meeting.mp3")
438
+
439
+ puts result["content"]
440
+ ```
441
+
442
+ By default, `content` is the transcript text:
443
+
444
+ ```ruby
445
+ {
446
+ "content" => "Welcome everyone, let's get started.",
447
+ "response_id" => nil,
448
+ "status" => 200,
449
+ "error" => nil,
450
+ "raw" => nil
451
+ }
452
+ ```
453
+
454
+ `transcribe` sends a multipart `POST` request to `/v1/audio/transcriptions` with:
455
+
456
+ - `file`
457
+ - `model`
458
+ - optional `language`
459
+ - optional `prompt`
460
+ - optional `response_format`
461
+ - optional `temperature`
462
+ - optional `timestamp_granularities`
463
+ - optional `debug`
464
+ - optional extra `options`
465
+
466
+ The default transcription model is `gpt-transcribe`.
467
+
468
+ Supported local audio extensions are `.flac`, `.m4a`, `.mp3`, `.mp4`, `.mpeg`, `.mpga`, `.ogg`, `.wav`, and `.webm`.
469
+
470
+ Pass `language` in ISO-639-1 format when you know the input language:
471
+
472
+ ```ruby
473
+ result = ai.transcribe(
474
+ "tmp/meeting.mp3",
475
+ language: "en"
476
+ )
477
+ ```
478
+
479
+ Use `prompt` to provide words, names, or style context that may help the transcription:
480
+
481
+ ```ruby
482
+ result = ai.transcribe(
483
+ "tmp/support-call.mp3",
484
+ prompt: "The speakers may mention AI Lite, RubyGems, and Net::HTTP."
485
+ )
486
+ ```
487
+
488
+ Pass `debug: true` to include the raw OpenAI response:
489
+
490
+ ```ruby
491
+ result = ai.transcribe("tmp/meeting.mp3", debug: true)
492
+
493
+ result["content"] # transcript text
494
+ result["raw"] # full response body when available
322
495
  ```
323
496
 
324
497
  ## Return Shape
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.4.0".freeze
2
+ VERSION = "0.6.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "base64"
2
2
  require "json"
3
3
  require "net/http"
4
+ require "securerandom"
4
5
  require "uri"
5
6
  require_relative "ai_lite/version"
6
7
 
@@ -10,6 +11,10 @@ class AiLite
10
11
  DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
12
  DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
12
13
  DEFAULT_IMAGE_MODEL = "gpt-image-2".freeze
14
+ DEFAULT_SPEECH_MODEL = "gpt-4o-mini-tts".freeze
15
+ DEFAULT_SPEECH_VOICE = "alloy".freeze
16
+ DEFAULT_SPEECH_FORMAT = "mp3".freeze
17
+ DEFAULT_TRANSCRIPTION_MODEL = "gpt-transcribe".freeze
13
18
  DEFAULT_TIMEOUT = 120
14
19
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
15
20
  IMAGE_MIME_TYPES = {
@@ -19,9 +24,20 @@ class AiLite
19
24
  ".png" => "image/png",
20
25
  ".webp" => "image/webp"
21
26
  }.freeze
27
+ AUDIO_MIME_TYPES = {
28
+ ".flac" => "audio/flac",
29
+ ".m4a" => "audio/mp4",
30
+ ".mp3" => "audio/mpeg",
31
+ ".mp4" => "audio/mp4",
32
+ ".mpeg" => "audio/mpeg",
33
+ ".mpga" => "audio/mpeg",
34
+ ".ogg" => "audio/ogg",
35
+ ".wav" => "audio/wav",
36
+ ".webm" => "audio/webm"
37
+ }.freeze
22
38
 
23
39
  class Configuration
24
- attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens
40
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens
25
41
 
26
42
  def initialize
27
43
  @api_key = nil
@@ -29,6 +45,9 @@ class AiLite
29
45
  @moderation_model = DEFAULT_MODERATION_MODEL
30
46
  @embedding_model = DEFAULT_EMBEDDING_MODEL
31
47
  @image_model = DEFAULT_IMAGE_MODEL
48
+ @speech_model = DEFAULT_SPEECH_MODEL
49
+ @speech_voice = DEFAULT_SPEECH_VOICE
50
+ @transcription_model = DEFAULT_TRANSCRIPTION_MODEL
32
51
  @timeout = DEFAULT_TIMEOUT
33
52
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
34
53
  end
@@ -71,14 +90,22 @@ class AiLite
71
90
  client.image(prompt, **kwargs)
72
91
  end
73
92
 
93
+ def speak(text, **kwargs)
94
+ client.speak(text, **kwargs)
95
+ end
96
+
97
+ def transcribe(file_path, **kwargs)
98
+ client.transcribe(file_path, **kwargs)
99
+ end
100
+
74
101
  def reset_client!
75
102
  @client = nil
76
103
  end
77
104
  end
78
105
 
79
- attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens, :headers
106
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens, :headers
80
107
 
81
- def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, timeout: nil, max_output_tokens: nil)
108
+ def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, speech_model: nil, speech_voice: nil, transcription_model: nil, timeout: nil, max_output_tokens: nil)
82
109
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
83
110
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
84
111
 
@@ -86,6 +113,9 @@ class AiLite
86
113
  @moderation_model = moderation_model || self.class.configuration.moderation_model
87
114
  @embedding_model = embedding_model || self.class.configuration.embedding_model
88
115
  @image_model = image_model || self.class.configuration.image_model
116
+ @speech_model = speech_model || self.class.configuration.speech_model
117
+ @speech_voice = speech_voice || self.class.configuration.speech_voice
118
+ @transcription_model = transcription_model || self.class.configuration.transcription_model
89
119
  @timeout = timeout || self.class.configuration.timeout
90
120
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
91
121
  @headers = {
@@ -146,6 +176,45 @@ class AiLite
146
176
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
147
177
  end
148
178
 
179
+ def speak(text, model: nil, voice: nil, response_format: nil, speed: nil, instructions: nil, output_path: nil, base64: false, debug: false, options: {})
180
+ payload = options.merge(
181
+ model: model || speech_model,
182
+ input: text,
183
+ voice: voice || speech_voice
184
+ )
185
+ payload[:response_format] = response_format if response_format
186
+ payload[:speed] = speed if speed
187
+ payload[:instructions] = instructions if instructions
188
+
189
+ extract_speech(
190
+ post(payload, endpoint: speech_endpoint),
191
+ output_path: output_path,
192
+ base64: base64,
193
+ response_format: payload[:response_format] || payload["response_format"] || DEFAULT_SPEECH_FORMAT,
194
+ debug: debug
195
+ )
196
+ rescue => e
197
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
198
+ end
199
+
200
+ def transcribe(file_path, model: nil, language: nil, prompt: nil, response_format: nil, temperature: nil, timestamp_granularities: nil, debug: false, options: {})
201
+ fields = options.merge(
202
+ model: model || transcription_model
203
+ )
204
+ fields[:language] = language if language
205
+ fields[:prompt] = prompt if prompt
206
+ fields[:response_format] = response_format if response_format
207
+ fields[:temperature] = temperature unless temperature.nil?
208
+ fields[:timestamp_granularities] = timestamp_granularities if timestamp_granularities
209
+
210
+ extract_transcription(
211
+ post_multipart(fields, file_field: audio_file_field(file_path), endpoint: transcription_endpoint),
212
+ debug: debug
213
+ )
214
+ rescue => e
215
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
216
+ end
217
+
149
218
  private
150
219
 
151
220
  def post(payload, endpoint: response_endpoint)
@@ -165,6 +234,21 @@ class AiLite
165
234
  end
166
235
  end
167
236
 
237
+ def post_multipart(fields, file_field:, endpoint:)
238
+ uri = URI.parse(endpoint)
239
+ boundary = "----AiLiteBoundary#{SecureRandom.hex(16)}"
240
+ request = Net::HTTP::Post.new(uri)
241
+ request["Authorization"] = headers["Authorization"]
242
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
243
+ request.body = multipart_body(fields, file_field: file_field, boundary: boundary)
244
+
245
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
246
+ http.open_timeout = timeout if http.respond_to?(:open_timeout=)
247
+ http.read_timeout = timeout if http.respond_to?(:read_timeout=)
248
+ http.request(request)
249
+ end
250
+ end
251
+
168
252
  def response_endpoint
169
253
  "#{API_BASE_URL}/responses"
170
254
  end
@@ -181,6 +265,14 @@ class AiLite
181
265
  "#{API_BASE_URL}/images/generations"
182
266
  end
183
267
 
268
+ def speech_endpoint
269
+ "#{API_BASE_URL}/audio/speech"
270
+ end
271
+
272
+ def transcription_endpoint
273
+ "#{API_BASE_URL}/audio/transcriptions"
274
+ end
275
+
184
276
  def extract_content(response, debug: false)
185
277
  status = response.code.to_i
186
278
  parsed_response = JSON.parse(response.body)
@@ -294,6 +386,60 @@ class AiLite
294
386
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
295
387
  end
296
388
 
389
+ def extract_speech(response, output_path:, base64:, response_format:, debug: false)
390
+ status = response.code.to_i
391
+
392
+ unless success_status?(status)
393
+ parsed_response = parse_error_response(response.body)
394
+
395
+ return prettify_data(
396
+ status: status,
397
+ error: error_message(parsed_response),
398
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
399
+ raw: parsed_response,
400
+ debug: debug
401
+ )
402
+ end
403
+
404
+ audio = response.body
405
+ File.binwrite(output_path, audio) if output_path
406
+
407
+ prettify_data(
408
+ status: status,
409
+ content: speech_content(audio, output_path: output_path, base64: base64, response_format: response_format),
410
+ response_id: nil,
411
+ raw: audio,
412
+ debug: debug
413
+ )
414
+ rescue => e
415
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
416
+ end
417
+
418
+ def extract_transcription(response, debug: false)
419
+ status = response.code.to_i
420
+ parsed_response = parse_error_response(response.body)
421
+
422
+ unless success_status?(status)
423
+ return prettify_data(
424
+ status: status,
425
+ error: error_message(parsed_response),
426
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
427
+ raw: parsed_response,
428
+ debug: debug
429
+ )
430
+ end
431
+
432
+ prettify_data(
433
+ status: status,
434
+ content: transcription_content(parsed_response),
435
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
436
+ raw: parsed_response,
437
+ debug: debug
438
+ )
439
+ rescue => e
440
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
441
+ end
442
+
297
443
  def extract_output_text(raw)
298
444
  Array(raw["output"]).flat_map do |item|
299
445
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -353,6 +499,24 @@ class AiLite
353
499
  end
354
500
  end
355
501
 
502
+ def audio_file_field(path)
503
+ raise ArgumentError, "Audio file not found: #{path}" unless File.file?(path)
504
+
505
+ {
506
+ name: "file",
507
+ path: path,
508
+ filename: File.basename(path),
509
+ content_type: audio_mime_type(path)
510
+ }
511
+ end
512
+
513
+ def audio_mime_type(path)
514
+ extension = File.extname(path).downcase
515
+ AUDIO_MIME_TYPES.fetch(extension) do
516
+ raise ArgumentError, "Unsupported audio type for transcription: #{extension}"
517
+ end
518
+ end
519
+
356
520
  def moderation_content(raw)
357
521
  results = raw["results"]
358
522
  return nil unless results.is_a?(Array)
@@ -379,6 +543,72 @@ class AiLite
379
543
  File.binwrite(path, Base64.decode64(content))
380
544
  end
381
545
 
546
+ def speech_content(audio, output_path:, base64:, response_format:)
547
+ return Base64.strict_encode64(audio) if base64
548
+ return audio unless output_path
549
+
550
+ {
551
+ "path" => output_path,
552
+ "bytes" => audio.bytesize,
553
+ "format" => response_format
554
+ }
555
+ end
556
+
557
+ def transcription_content(raw)
558
+ return raw["text"] if raw.is_a?(Hash) && raw.key?("text")
559
+
560
+ raw
561
+ end
562
+
563
+ def multipart_body(fields, file_field:, boundary:)
564
+ body = String.new(encoding: Encoding::BINARY)
565
+
566
+ fields.each do |name, value|
567
+ multipart_field_parts(name, value).each do |field_name, field_value|
568
+ body << "--#{boundary}\r\n".b
569
+ body << "Content-Disposition: form-data; name=\"#{multipart_quote(field_name)}\"\r\n\r\n".b
570
+ body << field_value.to_s.b
571
+ body << "\r\n".b
572
+ end
573
+ end
574
+
575
+ body << "--#{boundary}\r\n".b
576
+ body << "Content-Disposition: form-data; name=\"#{multipart_quote(file_field[:name])}\"; filename=\"#{multipart_quote(file_field[:filename])}\"\r\n".b
577
+ body << "Content-Type: #{file_field[:content_type]}\r\n\r\n".b
578
+ body << File.binread(file_field[:path])
579
+ body << "\r\n--#{boundary}--\r\n".b
580
+ body
581
+ end
582
+
583
+ def multipart_field_parts(name, value)
584
+ return [] if value.nil?
585
+
586
+ if value.is_a?(Array)
587
+ value.map { |item| ["#{name}[]", multipart_value(item)] }
588
+ else
589
+ [[name.to_s, multipart_value(value)]]
590
+ end
591
+ end
592
+
593
+ def multipart_value(value)
594
+ case value
595
+ when Hash
596
+ JSON.generate(value)
597
+ else
598
+ value
599
+ end
600
+ end
601
+
602
+ def multipart_quote(value)
603
+ value.to_s.gsub("\\", "\\\\").gsub("\"", "\\\"").delete("\r\n")
604
+ end
605
+
606
+ def parse_error_response(body)
607
+ JSON.parse(body)
608
+ rescue JSON::ParserError
609
+ body
610
+ end
611
+
382
612
  def success_status?(status)
383
613
  status >= 200 && status < 300
384
614
  end
data/test/ai_lite_test.rb CHANGED
@@ -34,6 +34,9 @@ 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",
39
+ transcription_model: "gpt-transcribe-test",
37
40
  timeout: 10
38
41
  )
39
42
 
@@ -42,6 +45,9 @@ class AiLiteTest < Minitest::Test
42
45
  assert_equal "omni-moderation-latest", client.moderation_model
43
46
  assert_equal "text-embedding-3-small", client.embedding_model
44
47
  assert_equal "gpt-image-test", client.image_model
48
+ assert_equal "gpt-speech-test", client.speech_model
49
+ assert_equal "verse", client.speech_voice
50
+ assert_equal "gpt-transcribe-test", client.transcription_model
45
51
  assert_equal 10, client.timeout
46
52
  assert_equal 2000, client.max_output_tokens
47
53
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -57,6 +63,9 @@ class AiLiteTest < Minitest::Test
57
63
  config.moderation_model = "omni-moderation-test"
58
64
  config.embedding_model = "text-embedding-test"
59
65
  config.image_model = "gpt-image-test"
66
+ config.speech_model = "gpt-speech-test"
67
+ config.speech_voice = "verse"
68
+ config.transcription_model = "gpt-transcribe-test"
60
69
  config.timeout = 15
61
70
  config.max_output_tokens = 750
62
71
  end
@@ -68,6 +77,9 @@ class AiLiteTest < Minitest::Test
68
77
  assert_equal "omni-moderation-test", client.moderation_model
69
78
  assert_equal "text-embedding-test", client.embedding_model
70
79
  assert_equal "gpt-image-test", client.image_model
80
+ assert_equal "gpt-speech-test", client.speech_model
81
+ assert_equal "verse", client.speech_voice
82
+ assert_equal "gpt-transcribe-test", client.transcription_model
71
83
  assert_equal 15, client.timeout
72
84
  assert_equal 750, client.max_output_tokens
73
85
  assert_same client, AiLite.client
@@ -92,6 +104,9 @@ class AiLiteTest < Minitest::Test
92
104
  config.moderation_model = "omni-moderation-config"
93
105
  config.embedding_model = "text-embedding-config"
94
106
  config.image_model = "gpt-image-config"
107
+ config.speech_model = "gpt-speech-config"
108
+ config.speech_voice = "sage"
109
+ config.transcription_model = "gpt-transcribe-config"
95
110
  config.timeout = 15
96
111
  config.max_output_tokens = 750
97
112
  end
@@ -102,6 +117,9 @@ class AiLiteTest < Minitest::Test
102
117
  moderation_model: "omni-moderation-explicit",
103
118
  embedding_model: "text-embedding-explicit",
104
119
  image_model: "gpt-image-explicit",
120
+ speech_model: "gpt-speech-explicit",
121
+ speech_voice: "coral",
122
+ transcription_model: "gpt-transcribe-explicit",
105
123
  timeout: 5,
106
124
  max_output_tokens: 300
107
125
  )
@@ -111,6 +129,9 @@ class AiLiteTest < Minitest::Test
111
129
  assert_equal "omni-moderation-explicit", client.moderation_model
112
130
  assert_equal "text-embedding-explicit", client.embedding_model
113
131
  assert_equal "gpt-image-explicit", client.image_model
132
+ assert_equal "gpt-speech-explicit", client.speech_model
133
+ assert_equal "coral", client.speech_voice
134
+ assert_equal "gpt-transcribe-explicit", client.transcription_model
114
135
  assert_equal 5, client.timeout
115
136
  assert_equal 300, client.max_output_tokens
116
137
  end
@@ -644,6 +665,298 @@ class AiLiteTest < Minitest::Test
644
665
  end
645
666
  end
646
667
 
668
+ def test_speak_sends_post_to_audio_speech_with_default_payload
669
+ client = AiLite.new(api_key: "token-abc")
670
+
671
+ with_stubbed_http(speech_response("fake audio")) do |captured, _response|
672
+ result = client.speak("Read this aloud")
673
+ request = captured[:http].last_request
674
+ payload = JSON.parse(request.body)
675
+
676
+ assert_equal "fake audio", result["content"]
677
+ assert_nil result["response_id"]
678
+ assert_equal 200, result["status"]
679
+ assert_nil result["error"]
680
+ assert_nil result["raw"]
681
+ assert_equal "api.openai.com", captured[:host]
682
+ assert_equal 443, captured[:port]
683
+ assert_equal true, captured[:use_ssl]
684
+ assert_instance_of Net::HTTP::Post, request
685
+ assert_equal "/v1/audio/speech", request.path
686
+ assert_equal "Bearer token-abc", request["Authorization"]
687
+ assert_equal "application/json", request["Content-Type"]
688
+ assert_equal "gpt-4o-mini-tts", payload["model"]
689
+ assert_equal "Read this aloud", payload["input"]
690
+ assert_equal "alloy", payload["voice"]
691
+ end
692
+ end
693
+
694
+ def test_speak_includes_options_response_format_speed_instructions_model_and_voice
695
+ client = AiLite.new(api_key: "token-abc")
696
+
697
+ with_stubbed_http(speech_response) do |captured, _response|
698
+ client.speak(
699
+ "Use a clear support tone",
700
+ model: "gpt-speech-test",
701
+ voice: "sage",
702
+ response_format: "wav",
703
+ speed: 1.2,
704
+ instructions: "Speak warmly.",
705
+ options: {
706
+ stream_format: "audio"
707
+ }
708
+ )
709
+ payload = JSON.parse(captured[:http].last_request.body)
710
+
711
+ assert_equal "gpt-speech-test", payload["model"]
712
+ assert_equal "Use a clear support tone", payload["input"]
713
+ assert_equal "sage", payload["voice"]
714
+ assert_equal "wav", payload["response_format"]
715
+ assert_equal 1.2, payload["speed"]
716
+ assert_equal "Speak warmly.", payload["instructions"]
717
+ assert_equal "audio", payload["stream_format"]
718
+ end
719
+ end
720
+
721
+ def test_speak_uses_class_level_configured_client
722
+ AiLite.configure do |config|
723
+ config.api_key = "configured-key"
724
+ config.speech_model = "gpt-speech-config"
725
+ config.speech_voice = "marin"
726
+ end
727
+
728
+ with_stubbed_http(speech_response) do |captured, _response|
729
+ AiLite.speak("Use configured defaults")
730
+ payload = JSON.parse(captured[:http].last_request.body)
731
+
732
+ assert_equal "gpt-speech-config", payload["model"]
733
+ assert_equal "marin", payload["voice"]
734
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
735
+ end
736
+ end
737
+
738
+ def test_speak_writes_audio_to_output_path_and_returns_metadata
739
+ client = AiLite.new(api_key: "token-abc")
740
+
741
+ Tempfile.create(["speech", ".mp3"]) do |file|
742
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
743
+ result = client.speak("Save this", output_path: file.path)
744
+
745
+ assert_equal(
746
+ {
747
+ "path" => file.path,
748
+ "bytes" => 10,
749
+ "format" => "mp3"
750
+ },
751
+ result["content"]
752
+ )
753
+ assert_equal "fake audio", File.binread(file.path)
754
+ end
755
+ end
756
+ end
757
+
758
+ def test_speak_base64_true_returns_encoded_audio
759
+ client = AiLite.new(api_key: "token-abc")
760
+
761
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
762
+ result = client.speak("Encode this", base64: true)
763
+
764
+ assert_equal Base64.strict_encode64("fake audio"), result["content"]
765
+ assert_nil result["raw"]
766
+ end
767
+ end
768
+
769
+ def test_speak_debug_true_returns_raw_audio
770
+ client = AiLite.new(api_key: "token-abc")
771
+
772
+ with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
773
+ result = client.speak("Debug this", debug: true)
774
+
775
+ assert_equal "fake audio", result["content"]
776
+ assert_equal "fake audio", result["raw"]
777
+ end
778
+ end
779
+
780
+ def test_speak_http_errors_return_standard_envelope
781
+ client = AiLite.new(api_key: "token-abc")
782
+ body = JSON.generate("error" => { "message" => "Unsupported voice" })
783
+
784
+ with_stubbed_http(FakeResponse.new("400", body)) do |_captured, _response|
785
+ result = client.speak("Say hello")
786
+
787
+ assert_nil result["content"]
788
+ assert_nil result["response_id"]
789
+ assert_equal 400, result["status"]
790
+ assert_equal "Unsupported voice", result["error"]
791
+ assert_nil result["raw"]
792
+ end
793
+ end
794
+
795
+ def test_transcribe_sends_post_to_audio_transcriptions_with_default_multipart_payload
796
+ client = AiLite.new(api_key: "token-abc")
797
+
798
+ Tempfile.create(["meeting", ".mp3"]) do |file|
799
+ file.binmode
800
+ file.write("fake audio")
801
+ file.flush
802
+
803
+ with_stubbed_http(transcription_response("Hello from the file")) do |captured, _response|
804
+ result = client.transcribe(file.path)
805
+ request = captured[:http].last_request
806
+
807
+ assert_equal "Hello from the file", result["content"]
808
+ assert_nil result["response_id"]
809
+ assert_equal 200, result["status"]
810
+ assert_nil result["error"]
811
+ assert_nil result["raw"]
812
+ assert_equal "api.openai.com", captured[:host]
813
+ assert_equal 443, captured[:port]
814
+ assert_equal true, captured[:use_ssl]
815
+ assert_instance_of Net::HTTP::Post, request
816
+ assert_equal "/v1/audio/transcriptions", request.path
817
+ assert_equal "Bearer token-abc", request["Authorization"]
818
+ assert_match(/\Amultipart\/form-data; boundary=----AiLiteBoundary/, request["Content-Type"])
819
+ assert_multipart_field request.body, "model", "gpt-transcribe"
820
+ assert_multipart_file request.body, "file", File.basename(file.path), "audio/mpeg", "fake audio"
821
+ end
822
+ end
823
+ end
824
+
825
+ def test_transcribe_includes_options_and_optional_fields
826
+ client = AiLite.new(api_key: "token-abc")
827
+
828
+ Tempfile.create(["meeting", ".wav"]) do |file|
829
+ file.binmode
830
+ file.write("fake wav")
831
+ file.flush
832
+
833
+ with_stubbed_http(transcription_response("Detailed transcript")) do |captured, _response|
834
+ client.transcribe(
835
+ file.path,
836
+ model: "gpt-4o-transcribe",
837
+ language: "en",
838
+ prompt: "The speaker may say AI Lite.",
839
+ response_format: "verbose_json",
840
+ temperature: 0,
841
+ timestamp_granularities: ["word", "segment"],
842
+ options: {
843
+ chunking_strategy: "auto",
844
+ include: ["logprobs"],
845
+ metadata: { source: "test" }
846
+ }
847
+ )
848
+ body = captured[:http].last_request.body
849
+
850
+ assert_multipart_field body, "model", "gpt-4o-transcribe"
851
+ assert_multipart_field body, "language", "en"
852
+ assert_multipart_field body, "prompt", "The speaker may say AI Lite."
853
+ assert_multipart_field body, "response_format", "verbose_json"
854
+ assert_multipart_field body, "temperature", "0"
855
+ assert_multipart_field body, "timestamp_granularities[]", "word"
856
+ assert_multipart_field body, "timestamp_granularities[]", "segment"
857
+ assert_multipart_field body, "chunking_strategy", "auto"
858
+ assert_multipart_field body, "include[]", "logprobs"
859
+ assert_multipart_field body, "metadata", JSON.generate("source" => "test")
860
+ assert_multipart_file body, "file", File.basename(file.path), "audio/wav", "fake wav"
861
+ end
862
+ end
863
+ end
864
+
865
+ def test_transcribe_uses_class_level_configured_client
866
+ AiLite.configure do |config|
867
+ config.api_key = "configured-key"
868
+ config.transcription_model = "gpt-transcription-config"
869
+ end
870
+
871
+ Tempfile.create(["meeting", ".m4a"]) do |file|
872
+ file.binmode
873
+ file.write("fake m4a")
874
+ file.flush
875
+
876
+ with_stubbed_http(transcription_response) do |captured, _response|
877
+ AiLite.transcribe(file.path)
878
+ request = captured[:http].last_request
879
+
880
+ assert_multipart_field request.body, "model", "gpt-transcription-config"
881
+ assert_equal "Bearer configured-key", request["Authorization"]
882
+ end
883
+ end
884
+ end
885
+
886
+ def test_transcribe_returns_plain_text_response_formats
887
+ client = AiLite.new(api_key: "token-abc")
888
+
889
+ Tempfile.create(["meeting", ".webm"]) do |file|
890
+ file.binmode
891
+ file.write("fake webm")
892
+ file.flush
893
+
894
+ with_stubbed_http(FakeResponse.new("200", "plain transcript")) do |_captured, _response|
895
+ result = client.transcribe(file.path, response_format: "text")
896
+
897
+ assert_equal "plain transcript", result["content"]
898
+ assert_equal 200, result["status"]
899
+ assert_nil result["error"]
900
+ assert_nil result["raw"]
901
+ end
902
+ end
903
+ end
904
+
905
+ def test_transcribe_debug_true_returns_raw_response
906
+ client = AiLite.new(api_key: "token-abc")
907
+
908
+ Tempfile.create(["meeting", ".ogg"]) do |file|
909
+ file.binmode
910
+ file.write("fake ogg")
911
+ file.flush
912
+
913
+ with_stubbed_http(transcription_response("Debug transcript", usage: { "input_tokens" => 4 })) do |_captured, _response|
914
+ result = client.transcribe(file.path, debug: true)
915
+
916
+ assert_equal "Debug transcript", result["content"]
917
+ assert_equal({ "input_tokens" => 4 }, result["raw"]["usage"])
918
+ end
919
+ end
920
+ end
921
+
922
+ def test_transcribe_http_errors_return_standard_envelope
923
+ client = AiLite.new(api_key: "token-abc")
924
+ body = JSON.generate("error" => { "message" => "Unsupported audio format" })
925
+
926
+ Tempfile.create(["meeting", ".mp3"]) do |file|
927
+ file.binmode
928
+ file.write("fake audio")
929
+ file.flush
930
+
931
+ with_stubbed_http(FakeResponse.new("400", body)) do |_captured, _response|
932
+ result = client.transcribe(file.path)
933
+
934
+ assert_nil result["content"]
935
+ assert_nil result["response_id"]
936
+ assert_equal 400, result["status"]
937
+ assert_equal "Unsupported audio format", result["error"]
938
+ assert_nil result["raw"]
939
+ end
940
+ end
941
+ end
942
+
943
+ def test_transcribe_local_file_errors_return_standard_envelope
944
+ client = AiLite.new(api_key: "token-abc")
945
+
946
+ missing_result = client.transcribe("tmp/missing-audio.mp3")
947
+ assert_nil missing_result["content"]
948
+ assert_equal "unknown", missing_result["status"]
949
+ assert_equal "Audio file not found: tmp/missing-audio.mp3", missing_result["error"]
950
+
951
+ Tempfile.create(["meeting", ".txt"]) do |file|
952
+ result = client.transcribe(file.path)
953
+
954
+ assert_nil result["content"]
955
+ assert_equal "unknown", result["status"]
956
+ assert_equal "Unsupported audio type for transcription: .txt", result["error"]
957
+ end
958
+ end
959
+
647
960
  def test_http_errors_return_standard_envelope
648
961
  client = AiLite.new(api_key: "token-abc")
649
962
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -798,6 +1111,25 @@ class AiLiteTest < Minitest::Test
798
1111
  FakeResponse.new("200", body)
799
1112
  end
800
1113
 
1114
+ def speech_response(audio = "fake audio")
1115
+ FakeResponse.new("200", audio)
1116
+ end
1117
+
1118
+ def transcription_response(text = "Transcribed text", **extra)
1119
+ FakeResponse.new("200", JSON.generate({ "text" => text }.merge(extra)))
1120
+ end
1121
+
1122
+ def assert_multipart_field(body, name, value)
1123
+ assert_includes body, "Content-Disposition: form-data; name=\"#{name}\""
1124
+ assert_includes body, "\r\n\r\n#{value}\r\n"
1125
+ end
1126
+
1127
+ def assert_multipart_file(body, name, filename, content_type, content)
1128
+ assert_includes body, "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\""
1129
+ assert_includes body, "Content-Type: #{content_type}"
1130
+ assert_includes body, "\r\n\r\n#{content}\r\n"
1131
+ end
1132
+
801
1133
  def with_env(values)
802
1134
  originals = {}
803
1135
 
metadata CHANGED
@@ -1,17 +1,18 @@
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.6.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-09-16 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
- projects that need simple OpenAI Responses API calls.
14
+ projects that need simple OpenAI chat, moderation, embedding, image, speech, and
15
+ transcription calls.
15
16
  email:
16
17
  executables: []
17
18
  extensions: []
@@ -45,6 +46,5 @@ requirements: []
45
46
  rubygems_version: 3.5.22
46
47
  signing_key:
47
48
  specification_version: 4
48
- summary: Minimal Ruby client for simple AI chat calls through the OpenAI Responses
49
- API.
49
+ summary: Minimal Ruby client for simple OpenAI API calls.
50
50
  test_files: []