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 +4 -4
- data/README.md +183 -10
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +233 -3
- data/test/ai_lite_test.rb +332 -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
|
|
|
@@ -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
|
-
##
|
|
343
|
+
## Speech
|
|
308
344
|
|
|
309
|
-
|
|
345
|
+
Use `speak` to generate audio from text.
|
|
310
346
|
|
|
311
347
|
```ruby
|
|
312
|
-
|
|
348
|
+
result = ai.speak("Hello from AI Lite")
|
|
349
|
+
audio_bytes = result["content"]
|
|
350
|
+
```
|
|
313
351
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
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
|
|
|
@@ -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
|
+
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: []
|