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 +4 -4
- data/README.md +82 -1
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +151 -3
- data/test/ai_lite_test.rb +187 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2866b9f2b18c63bf07b51f6193b421bbb5fa8afc435ee8a50ce16d4debef9d04
|
|
4
|
+
data.tar.gz: d6bf73b903f387f2221b5d1c39e06de05901a7b3074161e59c773e649ccf4d6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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.
|
data/lib/ai_lite/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
|
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
|
|
49
|
-
API.
|
|
49
|
+
summary: Minimal Ruby client for simple OpenAI API calls.
|
|
50
50
|
test_files: []
|