ai-lite 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +101 -9
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +85 -3
- data/test/ai_lite_test.rb +145 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb52e45732e37e363b41144d47cc4136dab1feb450890f10cf84f7cc15593b2a
|
|
4
|
+
data.tar.gz: 2a05669e6fccdf8da0d1a3a68dc21598806e6072de2fda487e9b436751add386
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 307ffdbea749aac0eeca74b678d8af55a96add4924e1ef5e5f9157a83138b0929adc2db3282d79a924a24929d8c532fe1742f8ecb801409cf4d5c38fff3639ee
|
|
7
|
+
data.tar.gz: a50f9ef6171f3ee7249fc46d02e77698820272df70fe3ddbef777f6ab3159879c92c6d6d5fd7eddf29ceda3387308480a2622f95772b85b21fdbc2037808eda7
|
data/README.md
CHANGED
|
@@ -20,6 +20,7 @@ ai.chat("Say hello")
|
|
|
20
20
|
ai.moderate("User submitted text")
|
|
21
21
|
ai.embed("Text to vectorize")
|
|
22
22
|
ai.image("A simple app icon")
|
|
23
|
+
ai.speak("Read this aloud")
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
## Usage
|
|
@@ -53,6 +54,8 @@ AiLite.configure do |config|
|
|
|
53
54
|
config.moderation_model = "omni-moderation-latest"
|
|
54
55
|
config.embedding_model = "text-embedding-3-small"
|
|
55
56
|
config.image_model = "gpt-image-2"
|
|
57
|
+
config.speech_model = "gpt-4o-mini-tts"
|
|
58
|
+
config.speech_voice = "alloy"
|
|
56
59
|
config.timeout = 120
|
|
57
60
|
config.max_output_tokens = 2000
|
|
58
61
|
end
|
|
@@ -97,6 +100,23 @@ The default model is `gpt-5.5`.
|
|
|
97
100
|
|
|
98
101
|
The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
|
|
99
102
|
|
|
103
|
+
### Multi-Turn Chat
|
|
104
|
+
|
|
105
|
+
Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
first = ai.chat("Tell me a short joke.")
|
|
109
|
+
|
|
110
|
+
follow_up = ai.chat(
|
|
111
|
+
"Explain why that is funny.",
|
|
112
|
+
options: {
|
|
113
|
+
previous_response_id: first["response_id"]
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
puts follow_up["content"]
|
|
118
|
+
```
|
|
119
|
+
|
|
100
120
|
## Moderation
|
|
101
121
|
|
|
102
122
|
Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
|
|
@@ -304,21 +324,93 @@ result["content"] # base64 image data
|
|
|
304
324
|
result["raw"]["usage"] # token usage, when returned
|
|
305
325
|
```
|
|
306
326
|
|
|
307
|
-
##
|
|
327
|
+
## Speech
|
|
308
328
|
|
|
309
|
-
|
|
329
|
+
Use `speak` to generate audio from text.
|
|
310
330
|
|
|
311
331
|
```ruby
|
|
312
|
-
|
|
332
|
+
result = ai.speak("Hello from AI Lite")
|
|
333
|
+
audio_bytes = result["content"]
|
|
334
|
+
```
|
|
313
335
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
336
|
+
By default, `content` is the raw audio bytes returned by OpenAI:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
{
|
|
340
|
+
"content" => "...binary audio bytes...",
|
|
341
|
+
"response_id" => nil,
|
|
342
|
+
"status" => 200,
|
|
343
|
+
"error" => nil,
|
|
344
|
+
"raw" => nil
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Write the generated audio directly to a file with `output_path`:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
result = ai.speak(
|
|
352
|
+
"Hello from AI Lite",
|
|
353
|
+
output_path: "tmp/hello.mp3"
|
|
319
354
|
)
|
|
355
|
+
```
|
|
320
356
|
|
|
321
|
-
|
|
357
|
+
When `output_path` is used, `content` is file metadata:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
{
|
|
361
|
+
"content" => {
|
|
362
|
+
"path" => "tmp/hello.mp3",
|
|
363
|
+
"bytes" => 12345,
|
|
364
|
+
"format" => "mp3"
|
|
365
|
+
},
|
|
366
|
+
"response_id" => nil,
|
|
367
|
+
"status" => 200,
|
|
368
|
+
"error" => nil,
|
|
369
|
+
"raw" => nil
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use `base64: true` when you want text-safe audio data that can be transported in JSON and decoded later:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
result = ai.speak("Hello from AI Lite", base64: true)
|
|
377
|
+
|
|
378
|
+
File.binwrite("tmp/hello.mp3", Base64.decode64(result["content"]))
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`speak` sends a `POST` request to `/v1/audio/speech` with:
|
|
382
|
+
|
|
383
|
+
- `model`
|
|
384
|
+
- `input`
|
|
385
|
+
- `voice`
|
|
386
|
+
- optional `response_format`
|
|
387
|
+
- optional `speed`
|
|
388
|
+
- optional `instructions`
|
|
389
|
+
- optional `debug`
|
|
390
|
+
- optional extra `options`
|
|
391
|
+
|
|
392
|
+
The default speech model is `gpt-4o-mini-tts`.
|
|
393
|
+
The default speech voice is `alloy`.
|
|
394
|
+
The default response format is `mp3`.
|
|
395
|
+
|
|
396
|
+
Set `voice` per call when you want a different built-in voice:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
result = ai.speak(
|
|
400
|
+
"Hello from AI Lite",
|
|
401
|
+
voice: "sage",
|
|
402
|
+
output_path: "tmp/hello.mp3"
|
|
403
|
+
)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Use `response_format` to request `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm` output:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
result = ai.speak(
|
|
410
|
+
"Export this as a WAV file",
|
|
411
|
+
response_format: "wav",
|
|
412
|
+
output_path: "tmp/hello.wav"
|
|
413
|
+
)
|
|
322
414
|
```
|
|
323
415
|
|
|
324
416
|
## Return Shape
|
data/lib/ai_lite/version.rb
CHANGED
data/lib/ai_lite.rb
CHANGED
|
@@ -10,6 +10,9 @@ class AiLite
|
|
|
10
10
|
DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
|
|
11
11
|
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
|
|
12
12
|
DEFAULT_IMAGE_MODEL = "gpt-image-2".freeze
|
|
13
|
+
DEFAULT_SPEECH_MODEL = "gpt-4o-mini-tts".freeze
|
|
14
|
+
DEFAULT_SPEECH_VOICE = "alloy".freeze
|
|
15
|
+
DEFAULT_SPEECH_FORMAT = "mp3".freeze
|
|
13
16
|
DEFAULT_TIMEOUT = 120
|
|
14
17
|
DEFAULT_MAX_OUTPUT_TOKENS = 2000
|
|
15
18
|
IMAGE_MIME_TYPES = {
|
|
@@ -21,7 +24,7 @@ class AiLite
|
|
|
21
24
|
}.freeze
|
|
22
25
|
|
|
23
26
|
class Configuration
|
|
24
|
-
attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens
|
|
27
|
+
attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :timeout, :max_output_tokens
|
|
25
28
|
|
|
26
29
|
def initialize
|
|
27
30
|
@api_key = nil
|
|
@@ -29,6 +32,8 @@ class AiLite
|
|
|
29
32
|
@moderation_model = DEFAULT_MODERATION_MODEL
|
|
30
33
|
@embedding_model = DEFAULT_EMBEDDING_MODEL
|
|
31
34
|
@image_model = DEFAULT_IMAGE_MODEL
|
|
35
|
+
@speech_model = DEFAULT_SPEECH_MODEL
|
|
36
|
+
@speech_voice = DEFAULT_SPEECH_VOICE
|
|
32
37
|
@timeout = DEFAULT_TIMEOUT
|
|
33
38
|
@max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
|
|
34
39
|
end
|
|
@@ -71,14 +76,18 @@ class AiLite
|
|
|
71
76
|
client.image(prompt, **kwargs)
|
|
72
77
|
end
|
|
73
78
|
|
|
79
|
+
def speak(text, **kwargs)
|
|
80
|
+
client.speak(text, **kwargs)
|
|
81
|
+
end
|
|
82
|
+
|
|
74
83
|
def reset_client!
|
|
75
84
|
@client = nil
|
|
76
85
|
end
|
|
77
86
|
end
|
|
78
87
|
|
|
79
|
-
attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :timeout, :max_output_tokens, :headers
|
|
88
|
+
attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :timeout, :max_output_tokens, :headers
|
|
80
89
|
|
|
81
|
-
def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, timeout: nil, max_output_tokens: nil)
|
|
90
|
+
def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, image_model: nil, speech_model: nil, speech_voice: nil, timeout: nil, max_output_tokens: nil)
|
|
82
91
|
@api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
|
|
83
92
|
raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
|
|
84
93
|
|
|
@@ -86,6 +95,8 @@ class AiLite
|
|
|
86
95
|
@moderation_model = moderation_model || self.class.configuration.moderation_model
|
|
87
96
|
@embedding_model = embedding_model || self.class.configuration.embedding_model
|
|
88
97
|
@image_model = image_model || self.class.configuration.image_model
|
|
98
|
+
@speech_model = speech_model || self.class.configuration.speech_model
|
|
99
|
+
@speech_voice = speech_voice || self.class.configuration.speech_voice
|
|
89
100
|
@timeout = timeout || self.class.configuration.timeout
|
|
90
101
|
@max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
|
|
91
102
|
@headers = {
|
|
@@ -146,6 +157,27 @@ class AiLite
|
|
|
146
157
|
prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
|
|
147
158
|
end
|
|
148
159
|
|
|
160
|
+
def speak(text, model: nil, voice: nil, response_format: nil, speed: nil, instructions: nil, output_path: nil, base64: false, debug: false, options: {})
|
|
161
|
+
payload = options.merge(
|
|
162
|
+
model: model || speech_model,
|
|
163
|
+
input: text,
|
|
164
|
+
voice: voice || speech_voice
|
|
165
|
+
)
|
|
166
|
+
payload[:response_format] = response_format if response_format
|
|
167
|
+
payload[:speed] = speed if speed
|
|
168
|
+
payload[:instructions] = instructions if instructions
|
|
169
|
+
|
|
170
|
+
extract_speech(
|
|
171
|
+
post(payload, endpoint: speech_endpoint),
|
|
172
|
+
output_path: output_path,
|
|
173
|
+
base64: base64,
|
|
174
|
+
response_format: payload[:response_format] || payload["response_format"] || DEFAULT_SPEECH_FORMAT,
|
|
175
|
+
debug: debug
|
|
176
|
+
)
|
|
177
|
+
rescue => e
|
|
178
|
+
prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
|
|
179
|
+
end
|
|
180
|
+
|
|
149
181
|
private
|
|
150
182
|
|
|
151
183
|
def post(payload, endpoint: response_endpoint)
|
|
@@ -181,6 +213,10 @@ class AiLite
|
|
|
181
213
|
"#{API_BASE_URL}/images/generations"
|
|
182
214
|
end
|
|
183
215
|
|
|
216
|
+
def speech_endpoint
|
|
217
|
+
"#{API_BASE_URL}/audio/speech"
|
|
218
|
+
end
|
|
219
|
+
|
|
184
220
|
def extract_content(response, debug: false)
|
|
185
221
|
status = response.code.to_i
|
|
186
222
|
parsed_response = JSON.parse(response.body)
|
|
@@ -294,6 +330,35 @@ class AiLite
|
|
|
294
330
|
prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
|
|
295
331
|
end
|
|
296
332
|
|
|
333
|
+
def extract_speech(response, output_path:, base64:, response_format:, debug: false)
|
|
334
|
+
status = response.code.to_i
|
|
335
|
+
|
|
336
|
+
unless success_status?(status)
|
|
337
|
+
parsed_response = parse_error_response(response.body)
|
|
338
|
+
|
|
339
|
+
return prettify_data(
|
|
340
|
+
status: status,
|
|
341
|
+
error: error_message(parsed_response),
|
|
342
|
+
response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
|
|
343
|
+
raw: parsed_response,
|
|
344
|
+
debug: debug
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
audio = response.body
|
|
349
|
+
File.binwrite(output_path, audio) if output_path
|
|
350
|
+
|
|
351
|
+
prettify_data(
|
|
352
|
+
status: status,
|
|
353
|
+
content: speech_content(audio, output_path: output_path, base64: base64, response_format: response_format),
|
|
354
|
+
response_id: nil,
|
|
355
|
+
raw: audio,
|
|
356
|
+
debug: debug
|
|
357
|
+
)
|
|
358
|
+
rescue => e
|
|
359
|
+
prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
|
|
360
|
+
end
|
|
361
|
+
|
|
297
362
|
def extract_output_text(raw)
|
|
298
363
|
Array(raw["output"]).flat_map do |item|
|
|
299
364
|
next [] unless item.is_a?(Hash) && item["type"] == "message"
|
|
@@ -379,6 +444,23 @@ class AiLite
|
|
|
379
444
|
File.binwrite(path, Base64.decode64(content))
|
|
380
445
|
end
|
|
381
446
|
|
|
447
|
+
def speech_content(audio, output_path:, base64:, response_format:)
|
|
448
|
+
return Base64.strict_encode64(audio) if base64
|
|
449
|
+
return audio unless output_path
|
|
450
|
+
|
|
451
|
+
{
|
|
452
|
+
"path" => output_path,
|
|
453
|
+
"bytes" => audio.bytesize,
|
|
454
|
+
"format" => response_format
|
|
455
|
+
}
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def parse_error_response(body)
|
|
459
|
+
JSON.parse(body)
|
|
460
|
+
rescue JSON::ParserError
|
|
461
|
+
body
|
|
462
|
+
end
|
|
463
|
+
|
|
382
464
|
def success_status?(status)
|
|
383
465
|
status >= 200 && status < 300
|
|
384
466
|
end
|
data/test/ai_lite_test.rb
CHANGED
|
@@ -34,6 +34,8 @@ class AiLiteTest < Minitest::Test
|
|
|
34
34
|
api_key: "explicit-key",
|
|
35
35
|
model: "gpt-test",
|
|
36
36
|
image_model: "gpt-image-test",
|
|
37
|
+
speech_model: "gpt-speech-test",
|
|
38
|
+
speech_voice: "verse",
|
|
37
39
|
timeout: 10
|
|
38
40
|
)
|
|
39
41
|
|
|
@@ -42,6 +44,8 @@ class AiLiteTest < Minitest::Test
|
|
|
42
44
|
assert_equal "omni-moderation-latest", client.moderation_model
|
|
43
45
|
assert_equal "text-embedding-3-small", client.embedding_model
|
|
44
46
|
assert_equal "gpt-image-test", client.image_model
|
|
47
|
+
assert_equal "gpt-speech-test", client.speech_model
|
|
48
|
+
assert_equal "verse", client.speech_voice
|
|
45
49
|
assert_equal 10, client.timeout
|
|
46
50
|
assert_equal 2000, client.max_output_tokens
|
|
47
51
|
assert_equal "Bearer explicit-key", client.headers["Authorization"]
|
|
@@ -57,6 +61,8 @@ class AiLiteTest < Minitest::Test
|
|
|
57
61
|
config.moderation_model = "omni-moderation-test"
|
|
58
62
|
config.embedding_model = "text-embedding-test"
|
|
59
63
|
config.image_model = "gpt-image-test"
|
|
64
|
+
config.speech_model = "gpt-speech-test"
|
|
65
|
+
config.speech_voice = "verse"
|
|
60
66
|
config.timeout = 15
|
|
61
67
|
config.max_output_tokens = 750
|
|
62
68
|
end
|
|
@@ -68,6 +74,8 @@ class AiLiteTest < Minitest::Test
|
|
|
68
74
|
assert_equal "omni-moderation-test", client.moderation_model
|
|
69
75
|
assert_equal "text-embedding-test", client.embedding_model
|
|
70
76
|
assert_equal "gpt-image-test", client.image_model
|
|
77
|
+
assert_equal "gpt-speech-test", client.speech_model
|
|
78
|
+
assert_equal "verse", client.speech_voice
|
|
71
79
|
assert_equal 15, client.timeout
|
|
72
80
|
assert_equal 750, client.max_output_tokens
|
|
73
81
|
assert_same client, AiLite.client
|
|
@@ -92,6 +100,8 @@ class AiLiteTest < Minitest::Test
|
|
|
92
100
|
config.moderation_model = "omni-moderation-config"
|
|
93
101
|
config.embedding_model = "text-embedding-config"
|
|
94
102
|
config.image_model = "gpt-image-config"
|
|
103
|
+
config.speech_model = "gpt-speech-config"
|
|
104
|
+
config.speech_voice = "sage"
|
|
95
105
|
config.timeout = 15
|
|
96
106
|
config.max_output_tokens = 750
|
|
97
107
|
end
|
|
@@ -102,6 +112,8 @@ class AiLiteTest < Minitest::Test
|
|
|
102
112
|
moderation_model: "omni-moderation-explicit",
|
|
103
113
|
embedding_model: "text-embedding-explicit",
|
|
104
114
|
image_model: "gpt-image-explicit",
|
|
115
|
+
speech_model: "gpt-speech-explicit",
|
|
116
|
+
speech_voice: "coral",
|
|
105
117
|
timeout: 5,
|
|
106
118
|
max_output_tokens: 300
|
|
107
119
|
)
|
|
@@ -111,6 +123,8 @@ class AiLiteTest < Minitest::Test
|
|
|
111
123
|
assert_equal "omni-moderation-explicit", client.moderation_model
|
|
112
124
|
assert_equal "text-embedding-explicit", client.embedding_model
|
|
113
125
|
assert_equal "gpt-image-explicit", client.image_model
|
|
126
|
+
assert_equal "gpt-speech-explicit", client.speech_model
|
|
127
|
+
assert_equal "coral", client.speech_voice
|
|
114
128
|
assert_equal 5, client.timeout
|
|
115
129
|
assert_equal 300, client.max_output_tokens
|
|
116
130
|
end
|
|
@@ -644,6 +658,133 @@ class AiLiteTest < Minitest::Test
|
|
|
644
658
|
end
|
|
645
659
|
end
|
|
646
660
|
|
|
661
|
+
def test_speak_sends_post_to_audio_speech_with_default_payload
|
|
662
|
+
client = AiLite.new(api_key: "token-abc")
|
|
663
|
+
|
|
664
|
+
with_stubbed_http(speech_response("fake audio")) do |captured, _response|
|
|
665
|
+
result = client.speak("Read this aloud")
|
|
666
|
+
request = captured[:http].last_request
|
|
667
|
+
payload = JSON.parse(request.body)
|
|
668
|
+
|
|
669
|
+
assert_equal "fake audio", result["content"]
|
|
670
|
+
assert_nil result["response_id"]
|
|
671
|
+
assert_equal 200, result["status"]
|
|
672
|
+
assert_nil result["error"]
|
|
673
|
+
assert_nil result["raw"]
|
|
674
|
+
assert_equal "api.openai.com", captured[:host]
|
|
675
|
+
assert_equal 443, captured[:port]
|
|
676
|
+
assert_equal true, captured[:use_ssl]
|
|
677
|
+
assert_instance_of Net::HTTP::Post, request
|
|
678
|
+
assert_equal "/v1/audio/speech", request.path
|
|
679
|
+
assert_equal "Bearer token-abc", request["Authorization"]
|
|
680
|
+
assert_equal "application/json", request["Content-Type"]
|
|
681
|
+
assert_equal "gpt-4o-mini-tts", payload["model"]
|
|
682
|
+
assert_equal "Read this aloud", payload["input"]
|
|
683
|
+
assert_equal "alloy", payload["voice"]
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def test_speak_includes_options_response_format_speed_instructions_model_and_voice
|
|
688
|
+
client = AiLite.new(api_key: "token-abc")
|
|
689
|
+
|
|
690
|
+
with_stubbed_http(speech_response) do |captured, _response|
|
|
691
|
+
client.speak(
|
|
692
|
+
"Use a clear support tone",
|
|
693
|
+
model: "gpt-speech-test",
|
|
694
|
+
voice: "sage",
|
|
695
|
+
response_format: "wav",
|
|
696
|
+
speed: 1.2,
|
|
697
|
+
instructions: "Speak warmly.",
|
|
698
|
+
options: {
|
|
699
|
+
stream_format: "audio"
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
payload = JSON.parse(captured[:http].last_request.body)
|
|
703
|
+
|
|
704
|
+
assert_equal "gpt-speech-test", payload["model"]
|
|
705
|
+
assert_equal "Use a clear support tone", payload["input"]
|
|
706
|
+
assert_equal "sage", payload["voice"]
|
|
707
|
+
assert_equal "wav", payload["response_format"]
|
|
708
|
+
assert_equal 1.2, payload["speed"]
|
|
709
|
+
assert_equal "Speak warmly.", payload["instructions"]
|
|
710
|
+
assert_equal "audio", payload["stream_format"]
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def test_speak_uses_class_level_configured_client
|
|
715
|
+
AiLite.configure do |config|
|
|
716
|
+
config.api_key = "configured-key"
|
|
717
|
+
config.speech_model = "gpt-speech-config"
|
|
718
|
+
config.speech_voice = "marin"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
with_stubbed_http(speech_response) do |captured, _response|
|
|
722
|
+
AiLite.speak("Use configured defaults")
|
|
723
|
+
payload = JSON.parse(captured[:http].last_request.body)
|
|
724
|
+
|
|
725
|
+
assert_equal "gpt-speech-config", payload["model"]
|
|
726
|
+
assert_equal "marin", payload["voice"]
|
|
727
|
+
assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def test_speak_writes_audio_to_output_path_and_returns_metadata
|
|
732
|
+
client = AiLite.new(api_key: "token-abc")
|
|
733
|
+
|
|
734
|
+
Tempfile.create(["speech", ".mp3"]) do |file|
|
|
735
|
+
with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
|
|
736
|
+
result = client.speak("Save this", output_path: file.path)
|
|
737
|
+
|
|
738
|
+
assert_equal(
|
|
739
|
+
{
|
|
740
|
+
"path" => file.path,
|
|
741
|
+
"bytes" => 10,
|
|
742
|
+
"format" => "mp3"
|
|
743
|
+
},
|
|
744
|
+
result["content"]
|
|
745
|
+
)
|
|
746
|
+
assert_equal "fake audio", File.binread(file.path)
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def test_speak_base64_true_returns_encoded_audio
|
|
752
|
+
client = AiLite.new(api_key: "token-abc")
|
|
753
|
+
|
|
754
|
+
with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
|
|
755
|
+
result = client.speak("Encode this", base64: true)
|
|
756
|
+
|
|
757
|
+
assert_equal Base64.strict_encode64("fake audio"), result["content"]
|
|
758
|
+
assert_nil result["raw"]
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def test_speak_debug_true_returns_raw_audio
|
|
763
|
+
client = AiLite.new(api_key: "token-abc")
|
|
764
|
+
|
|
765
|
+
with_stubbed_http(speech_response("fake audio")) do |_captured, _response|
|
|
766
|
+
result = client.speak("Debug this", debug: true)
|
|
767
|
+
|
|
768
|
+
assert_equal "fake audio", result["content"]
|
|
769
|
+
assert_equal "fake audio", result["raw"]
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def test_speak_http_errors_return_standard_envelope
|
|
774
|
+
client = AiLite.new(api_key: "token-abc")
|
|
775
|
+
body = JSON.generate("error" => { "message" => "Unsupported voice" })
|
|
776
|
+
|
|
777
|
+
with_stubbed_http(FakeResponse.new("400", body)) do |_captured, _response|
|
|
778
|
+
result = client.speak("Say hello")
|
|
779
|
+
|
|
780
|
+
assert_nil result["content"]
|
|
781
|
+
assert_nil result["response_id"]
|
|
782
|
+
assert_equal 400, result["status"]
|
|
783
|
+
assert_equal "Unsupported voice", result["error"]
|
|
784
|
+
assert_nil result["raw"]
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
647
788
|
def test_http_errors_return_standard_envelope
|
|
648
789
|
client = AiLite.new(api_key: "token-abc")
|
|
649
790
|
body = JSON.generate("error" => { "message" => "Invalid API key" })
|
|
@@ -798,6 +939,10 @@ class AiLiteTest < Minitest::Test
|
|
|
798
939
|
FakeResponse.new("200", body)
|
|
799
940
|
end
|
|
800
941
|
|
|
942
|
+
def speech_response(audio = "fake audio")
|
|
943
|
+
FakeResponse.new("200", audio)
|
|
944
|
+
end
|
|
945
|
+
|
|
801
946
|
def with_env(values)
|
|
802
947
|
originals = {}
|
|
803
948
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ai-lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- William Basmayor
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: AI Lite is a dependency-light Ruby client for Rails apps and plain Ruby
|
|
14
14
|
projects that need simple OpenAI Responses API calls.
|