ai-lite 0.5.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: cb52e45732e37e363b41144d47cc4136dab1feb450890f10cf84f7cc15593b2a
4
- data.tar.gz: 2a05669e6fccdf8da0d1a3a68dc21598806e6072de2fda487e9b436751add386
3
+ metadata.gz: 2866b9f2b18c63bf07b51f6193b421bbb5fa8afc435ee8a50ce16d4debef9d04
4
+ data.tar.gz: d6bf73b903f387f2221b5d1c39e06de05901a7b3074161e59c773e649ccf4d6f
5
5
  SHA512:
6
- metadata.gz: 307ffdbea749aac0eeca74b678d8af55a96add4924e1ef5e5f9157a83138b0929adc2db3282d79a924a24929d8c532fe1742f8ecb801409cf4d5c38fff3639ee
7
- data.tar.gz: a50f9ef6171f3ee7249fc46d02e77698820272df70fe3ddbef777f6ab3159879c92c6d6d5fd7eddf29ceda3387308480a2622f95772b85b21fdbc2037808eda7
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
 
@@ -21,6 +21,7 @@ ai.moderate("User submitted text")
21
21
  ai.embed("Text to vectorize")
22
22
  ai.image("A simple app icon")
23
23
  ai.speak("Read this aloud")
24
+ ai.transcribe("tmp/meeting.mp3")
24
25
  ```
25
26
 
26
27
  ## Usage
@@ -56,11 +57,26 @@ AiLite.configure do |config|
56
57
  config.image_model = "gpt-image-2"
57
58
  config.speech_model = "gpt-4o-mini-tts"
58
59
  config.speech_voice = "alloy"
60
+ config.transcription_model = "gpt-transcribe"
59
61
  config.timeout = 120
60
62
  config.max_output_tokens = 2000
61
63
  end
62
64
  ```
63
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
+
64
80
  Then use the configured singleton-style client:
65
81
 
66
82
  ```ruby
@@ -413,6 +429,71 @@ result = ai.speak(
413
429
  )
414
430
  ```
415
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
495
+ ```
496
+
416
497
  ## Return Shape
417
498
 
418
499
  Methods return a hash envelope.
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.5.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
 
@@ -13,6 +14,7 @@ class AiLite
13
14
  DEFAULT_SPEECH_MODEL = "gpt-4o-mini-tts".freeze
14
15
  DEFAULT_SPEECH_VOICE = "alloy".freeze
15
16
  DEFAULT_SPEECH_FORMAT = "mp3".freeze
17
+ DEFAULT_TRANSCRIPTION_MODEL = "gpt-transcribe".freeze
16
18
  DEFAULT_TIMEOUT = 120
17
19
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
18
20
  IMAGE_MIME_TYPES = {
@@ -22,9 +24,20 @@ class AiLite
22
24
  ".png" => "image/png",
23
25
  ".webp" => "image/webp"
24
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
25
38
 
26
39
  class Configuration
27
- attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :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
28
41
 
29
42
  def initialize
30
43
  @api_key = nil
@@ -34,6 +47,7 @@ class AiLite
34
47
  @image_model = DEFAULT_IMAGE_MODEL
35
48
  @speech_model = DEFAULT_SPEECH_MODEL
36
49
  @speech_voice = DEFAULT_SPEECH_VOICE
50
+ @transcription_model = DEFAULT_TRANSCRIPTION_MODEL
37
51
  @timeout = DEFAULT_TIMEOUT
38
52
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
39
53
  end
@@ -80,14 +94,18 @@ class AiLite
80
94
  client.speak(text, **kwargs)
81
95
  end
82
96
 
97
+ def transcribe(file_path, **kwargs)
98
+ client.transcribe(file_path, **kwargs)
99
+ end
100
+
83
101
  def reset_client!
84
102
  @client = nil
85
103
  end
86
104
  end
87
105
 
88
- attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :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
89
107
 
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)
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)
91
109
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
92
110
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
93
111
 
@@ -97,6 +115,7 @@ class AiLite
97
115
  @image_model = image_model || self.class.configuration.image_model
98
116
  @speech_model = speech_model || self.class.configuration.speech_model
99
117
  @speech_voice = speech_voice || self.class.configuration.speech_voice
118
+ @transcription_model = transcription_model || self.class.configuration.transcription_model
100
119
  @timeout = timeout || self.class.configuration.timeout
101
120
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
102
121
  @headers = {
@@ -178,6 +197,24 @@ class AiLite
178
197
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
179
198
  end
180
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
+
181
218
  private
182
219
 
183
220
  def post(payload, endpoint: response_endpoint)
@@ -197,6 +234,21 @@ class AiLite
197
234
  end
198
235
  end
199
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
+
200
252
  def response_endpoint
201
253
  "#{API_BASE_URL}/responses"
202
254
  end
@@ -217,6 +269,10 @@ class AiLite
217
269
  "#{API_BASE_URL}/audio/speech"
218
270
  end
219
271
 
272
+ def transcription_endpoint
273
+ "#{API_BASE_URL}/audio/transcriptions"
274
+ end
275
+
220
276
  def extract_content(response, debug: false)
221
277
  status = response.code.to_i
222
278
  parsed_response = JSON.parse(response.body)
@@ -359,6 +415,31 @@ class AiLite
359
415
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
360
416
  end
361
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
+
362
443
  def extract_output_text(raw)
363
444
  Array(raw["output"]).flat_map do |item|
364
445
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -418,6 +499,24 @@ class AiLite
418
499
  end
419
500
  end
420
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
+
421
520
  def moderation_content(raw)
422
521
  results = raw["results"]
423
522
  return nil unless results.is_a?(Array)
@@ -455,6 +554,55 @@ class AiLite
455
554
  }
456
555
  end
457
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
+
458
606
  def parse_error_response(body)
459
607
  JSON.parse(body)
460
608
  rescue JSON::ParserError
data/test/ai_lite_test.rb CHANGED
@@ -36,6 +36,7 @@ class AiLiteTest < Minitest::Test
36
36
  image_model: "gpt-image-test",
37
37
  speech_model: "gpt-speech-test",
38
38
  speech_voice: "verse",
39
+ transcription_model: "gpt-transcribe-test",
39
40
  timeout: 10
40
41
  )
41
42
 
@@ -46,6 +47,7 @@ class AiLiteTest < Minitest::Test
46
47
  assert_equal "gpt-image-test", client.image_model
47
48
  assert_equal "gpt-speech-test", client.speech_model
48
49
  assert_equal "verse", client.speech_voice
50
+ assert_equal "gpt-transcribe-test", client.transcription_model
49
51
  assert_equal 10, client.timeout
50
52
  assert_equal 2000, client.max_output_tokens
51
53
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -63,6 +65,7 @@ class AiLiteTest < Minitest::Test
63
65
  config.image_model = "gpt-image-test"
64
66
  config.speech_model = "gpt-speech-test"
65
67
  config.speech_voice = "verse"
68
+ config.transcription_model = "gpt-transcribe-test"
66
69
  config.timeout = 15
67
70
  config.max_output_tokens = 750
68
71
  end
@@ -76,6 +79,7 @@ class AiLiteTest < Minitest::Test
76
79
  assert_equal "gpt-image-test", client.image_model
77
80
  assert_equal "gpt-speech-test", client.speech_model
78
81
  assert_equal "verse", client.speech_voice
82
+ assert_equal "gpt-transcribe-test", client.transcription_model
79
83
  assert_equal 15, client.timeout
80
84
  assert_equal 750, client.max_output_tokens
81
85
  assert_same client, AiLite.client
@@ -102,6 +106,7 @@ class AiLiteTest < Minitest::Test
102
106
  config.image_model = "gpt-image-config"
103
107
  config.speech_model = "gpt-speech-config"
104
108
  config.speech_voice = "sage"
109
+ config.transcription_model = "gpt-transcribe-config"
105
110
  config.timeout = 15
106
111
  config.max_output_tokens = 750
107
112
  end
@@ -114,6 +119,7 @@ class AiLiteTest < Minitest::Test
114
119
  image_model: "gpt-image-explicit",
115
120
  speech_model: "gpt-speech-explicit",
116
121
  speech_voice: "coral",
122
+ transcription_model: "gpt-transcribe-explicit",
117
123
  timeout: 5,
118
124
  max_output_tokens: 300
119
125
  )
@@ -125,6 +131,7 @@ class AiLiteTest < Minitest::Test
125
131
  assert_equal "gpt-image-explicit", client.image_model
126
132
  assert_equal "gpt-speech-explicit", client.speech_model
127
133
  assert_equal "coral", client.speech_voice
134
+ assert_equal "gpt-transcribe-explicit", client.transcription_model
128
135
  assert_equal 5, client.timeout
129
136
  assert_equal 300, client.max_output_tokens
130
137
  end
@@ -785,6 +792,171 @@ class AiLiteTest < Minitest::Test
785
792
  end
786
793
  end
787
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
+
788
960
  def test_http_errors_return_standard_envelope
789
961
  client = AiLite.new(api_key: "token-abc")
790
962
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -943,6 +1115,21 @@ class AiLiteTest < Minitest::Test
943
1115
  FakeResponse.new("200", audio)
944
1116
  end
945
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
+
946
1133
  def with_env(values)
947
1134
  originals = {}
948
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.5.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-24 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: []