ai-lite 0.3.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 +166 -9
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +152 -3
- data/test/ai_lite_test.rb +284 -0
- metadata +7 -7
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
|
@@ -19,6 +19,8 @@ It is not meant to replace the official OpenAI SDK. It is a small wrapper for pr
|
|
|
19
19
|
ai.chat("Say hello")
|
|
20
20
|
ai.moderate("User submitted text")
|
|
21
21
|
ai.embed("Text to vectorize")
|
|
22
|
+
ai.image("A simple app icon")
|
|
23
|
+
ai.speak("Read this aloud")
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
## Usage
|
|
@@ -51,6 +53,9 @@ AiLite.configure do |config|
|
|
|
51
53
|
config.model = "gpt-5.5"
|
|
52
54
|
config.moderation_model = "omni-moderation-latest"
|
|
53
55
|
config.embedding_model = "text-embedding-3-small"
|
|
56
|
+
config.image_model = "gpt-image-2"
|
|
57
|
+
config.speech_model = "gpt-4o-mini-tts"
|
|
58
|
+
config.speech_voice = "alloy"
|
|
54
59
|
config.timeout = 120
|
|
55
60
|
config.max_output_tokens = 2000
|
|
56
61
|
end
|
|
@@ -95,6 +100,23 @@ The default model is `gpt-5.5`.
|
|
|
95
100
|
|
|
96
101
|
The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
|
|
97
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
|
+
|
|
98
120
|
## Moderation
|
|
99
121
|
|
|
100
122
|
Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
|
|
@@ -239,21 +261,156 @@ result["content"] # embedding vector
|
|
|
239
261
|
result["raw"]["usage"] # token usage
|
|
240
262
|
```
|
|
241
263
|
|
|
242
|
-
##
|
|
264
|
+
## Images
|
|
243
265
|
|
|
244
|
-
|
|
266
|
+
Use `image` to generate an image from a prompt.
|
|
245
267
|
|
|
246
268
|
```ruby
|
|
247
|
-
|
|
269
|
+
result = ai.image("A clean Ruby gem logo on a white background")
|
|
270
|
+
image_data = result["content"]
|
|
271
|
+
```
|
|
248
272
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
273
|
+
By default, `content` is the base64-encoded generated image:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
{
|
|
277
|
+
"content" => "iVBORw0KGgo...",
|
|
278
|
+
"response_id" => nil,
|
|
279
|
+
"status" => 200,
|
|
280
|
+
"error" => nil,
|
|
281
|
+
"raw" => nil
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Write the generated image bytes directly to a file with `output_path`:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
result = ai.image(
|
|
289
|
+
"A clean Ruby gem logo on a white background",
|
|
290
|
+
output_path: "tmp/logo.png"
|
|
254
291
|
)
|
|
292
|
+
```
|
|
255
293
|
|
|
256
|
-
|
|
294
|
+
`image` sends a `POST` request to `/v1/images/generations` with:
|
|
295
|
+
|
|
296
|
+
- `model`
|
|
297
|
+
- `prompt`
|
|
298
|
+
- optional `size`
|
|
299
|
+
- optional `quality`
|
|
300
|
+
- optional `background`
|
|
301
|
+
- optional `output_format`
|
|
302
|
+
- optional `debug`
|
|
303
|
+
- optional extra `options`
|
|
304
|
+
|
|
305
|
+
The default image model is `gpt-image-2`.
|
|
306
|
+
|
|
307
|
+
Use `output_format` to request `png`, `webp`, or `jpeg` output:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
result = ai.image(
|
|
311
|
+
"A transparent app icon",
|
|
312
|
+
background: "transparent",
|
|
313
|
+
output_format: "webp",
|
|
314
|
+
output_path: "tmp/icon.webp"
|
|
315
|
+
)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Pass `debug: true` to include the raw OpenAI response, including usage when returned:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
result = ai.image("A tiny robot sticker", debug: true)
|
|
322
|
+
|
|
323
|
+
result["content"] # base64 image data
|
|
324
|
+
result["raw"]["usage"] # token usage, when returned
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Speech
|
|
328
|
+
|
|
329
|
+
Use `speak` to generate audio from text.
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
result = ai.speak("Hello from AI Lite")
|
|
333
|
+
audio_bytes = result["content"]
|
|
334
|
+
```
|
|
335
|
+
|
|
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"
|
|
354
|
+
)
|
|
355
|
+
```
|
|
356
|
+
|
|
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
|
+
)
|
|
257
414
|
```
|
|
258
415
|
|
|
259
416
|
## Return Shape
|
data/lib/ai_lite/version.rb
CHANGED
data/lib/ai_lite.rb
CHANGED
|
@@ -9,6 +9,10 @@ class AiLite
|
|
|
9
9
|
DEFAULT_MODEL = "gpt-5.5".freeze
|
|
10
10
|
DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
|
|
11
11
|
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
|
|
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
|
|
12
16
|
DEFAULT_TIMEOUT = 120
|
|
13
17
|
DEFAULT_MAX_OUTPUT_TOKENS = 2000
|
|
14
18
|
IMAGE_MIME_TYPES = {
|
|
@@ -20,13 +24,16 @@ class AiLite
|
|
|
20
24
|
}.freeze
|
|
21
25
|
|
|
22
26
|
class Configuration
|
|
23
|
-
attr_accessor :api_key, :model, :moderation_model, :embedding_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
|
|
24
28
|
|
|
25
29
|
def initialize
|
|
26
30
|
@api_key = nil
|
|
27
31
|
@model = DEFAULT_MODEL
|
|
28
32
|
@moderation_model = DEFAULT_MODERATION_MODEL
|
|
29
33
|
@embedding_model = DEFAULT_EMBEDDING_MODEL
|
|
34
|
+
@image_model = DEFAULT_IMAGE_MODEL
|
|
35
|
+
@speech_model = DEFAULT_SPEECH_MODEL
|
|
36
|
+
@speech_voice = DEFAULT_SPEECH_VOICE
|
|
30
37
|
@timeout = DEFAULT_TIMEOUT
|
|
31
38
|
@max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
|
|
32
39
|
end
|
|
@@ -65,20 +72,31 @@ class AiLite
|
|
|
65
72
|
client.embed(input, **kwargs)
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
def image(prompt, **kwargs)
|
|
76
|
+
client.image(prompt, **kwargs)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def speak(text, **kwargs)
|
|
80
|
+
client.speak(text, **kwargs)
|
|
81
|
+
end
|
|
82
|
+
|
|
68
83
|
def reset_client!
|
|
69
84
|
@client = nil
|
|
70
85
|
end
|
|
71
86
|
end
|
|
72
87
|
|
|
73
|
-
attr_reader :api_key, :model, :moderation_model, :embedding_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
|
|
74
89
|
|
|
75
|
-
def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_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)
|
|
76
91
|
@api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
|
|
77
92
|
raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
|
|
78
93
|
|
|
79
94
|
@model = model || self.class.configuration.model
|
|
80
95
|
@moderation_model = moderation_model || self.class.configuration.moderation_model
|
|
81
96
|
@embedding_model = embedding_model || self.class.configuration.embedding_model
|
|
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
|
|
82
100
|
@timeout = timeout || self.class.configuration.timeout
|
|
83
101
|
@max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
|
|
84
102
|
@headers = {
|
|
@@ -124,6 +142,42 @@ class AiLite
|
|
|
124
142
|
prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
|
|
125
143
|
end
|
|
126
144
|
|
|
145
|
+
def image(prompt, model: nil, size: nil, quality: nil, background: nil, output_format: nil, output_path: nil, debug: false, options: {})
|
|
146
|
+
payload = options.merge(
|
|
147
|
+
model: model || image_model,
|
|
148
|
+
prompt: prompt
|
|
149
|
+
)
|
|
150
|
+
payload[:size] = size if size
|
|
151
|
+
payload[:quality] = quality if quality
|
|
152
|
+
payload[:background] = background if background
|
|
153
|
+
payload[:output_format] = output_format if output_format
|
|
154
|
+
|
|
155
|
+
extract_image(post(payload, endpoint: image_endpoint), output_path: output_path, debug: debug)
|
|
156
|
+
rescue => e
|
|
157
|
+
prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
|
|
158
|
+
end
|
|
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
|
+
|
|
127
181
|
private
|
|
128
182
|
|
|
129
183
|
def post(payload, endpoint: response_endpoint)
|
|
@@ -155,6 +209,14 @@ class AiLite
|
|
|
155
209
|
"#{API_BASE_URL}/embeddings"
|
|
156
210
|
end
|
|
157
211
|
|
|
212
|
+
def image_endpoint
|
|
213
|
+
"#{API_BASE_URL}/images/generations"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def speech_endpoint
|
|
217
|
+
"#{API_BASE_URL}/audio/speech"
|
|
218
|
+
end
|
|
219
|
+
|
|
158
220
|
def extract_content(response, debug: false)
|
|
159
221
|
status = response.code.to_i
|
|
160
222
|
parsed_response = JSON.parse(response.body)
|
|
@@ -238,6 +300,65 @@ class AiLite
|
|
|
238
300
|
prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
|
|
239
301
|
end
|
|
240
302
|
|
|
303
|
+
def extract_image(response, output_path:, debug: false)
|
|
304
|
+
status = response.code.to_i
|
|
305
|
+
parsed_response = JSON.parse(response.body)
|
|
306
|
+
|
|
307
|
+
unless success_status?(status)
|
|
308
|
+
return prettify_data(
|
|
309
|
+
status: status,
|
|
310
|
+
error: error_message(parsed_response),
|
|
311
|
+
response_id: parsed_response["id"],
|
|
312
|
+
raw: parsed_response,
|
|
313
|
+
debug: debug
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
content = image_content(parsed_response)
|
|
318
|
+
write_image_output(output_path, content) if output_path
|
|
319
|
+
|
|
320
|
+
prettify_data(
|
|
321
|
+
status: status,
|
|
322
|
+
content: content,
|
|
323
|
+
response_id: parsed_response["id"],
|
|
324
|
+
raw: parsed_response,
|
|
325
|
+
debug: debug
|
|
326
|
+
)
|
|
327
|
+
rescue JSON::ParserError => e
|
|
328
|
+
prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
|
|
329
|
+
rescue => e
|
|
330
|
+
prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
|
|
331
|
+
end
|
|
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
|
+
|
|
241
362
|
def extract_output_text(raw)
|
|
242
363
|
Array(raw["output"]).flat_map do |item|
|
|
243
364
|
next [] unless item.is_a?(Hash) && item["type"] == "message"
|
|
@@ -312,6 +433,34 @@ class AiLite
|
|
|
312
433
|
multiple ? embeddings : embeddings.first
|
|
313
434
|
end
|
|
314
435
|
|
|
436
|
+
def image_content(raw)
|
|
437
|
+
image = Array(raw["data"]).find { |item| item.is_a?(Hash) && item["b64_json"] }
|
|
438
|
+
image && image["b64_json"]
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def write_image_output(path, content)
|
|
442
|
+
raise "No image data returned" if content.to_s.empty?
|
|
443
|
+
|
|
444
|
+
File.binwrite(path, Base64.decode64(content))
|
|
445
|
+
end
|
|
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
|
+
|
|
315
464
|
def success_status?(status)
|
|
316
465
|
status >= 200 && status < 300
|
|
317
466
|
end
|
data/test/ai_lite_test.rb
CHANGED
|
@@ -33,6 +33,9 @@ class AiLiteTest < Minitest::Test
|
|
|
33
33
|
client = AiLite.new(
|
|
34
34
|
api_key: "explicit-key",
|
|
35
35
|
model: "gpt-test",
|
|
36
|
+
image_model: "gpt-image-test",
|
|
37
|
+
speech_model: "gpt-speech-test",
|
|
38
|
+
speech_voice: "verse",
|
|
36
39
|
timeout: 10
|
|
37
40
|
)
|
|
38
41
|
|
|
@@ -40,6 +43,9 @@ class AiLiteTest < Minitest::Test
|
|
|
40
43
|
assert_equal "gpt-test", client.model
|
|
41
44
|
assert_equal "omni-moderation-latest", client.moderation_model
|
|
42
45
|
assert_equal "text-embedding-3-small", client.embedding_model
|
|
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
|
|
43
49
|
assert_equal 10, client.timeout
|
|
44
50
|
assert_equal 2000, client.max_output_tokens
|
|
45
51
|
assert_equal "Bearer explicit-key", client.headers["Authorization"]
|
|
@@ -54,6 +60,9 @@ class AiLiteTest < Minitest::Test
|
|
|
54
60
|
config.model = "gpt-config"
|
|
55
61
|
config.moderation_model = "omni-moderation-test"
|
|
56
62
|
config.embedding_model = "text-embedding-test"
|
|
63
|
+
config.image_model = "gpt-image-test"
|
|
64
|
+
config.speech_model = "gpt-speech-test"
|
|
65
|
+
config.speech_voice = "verse"
|
|
57
66
|
config.timeout = 15
|
|
58
67
|
config.max_output_tokens = 750
|
|
59
68
|
end
|
|
@@ -64,6 +73,9 @@ class AiLiteTest < Minitest::Test
|
|
|
64
73
|
assert_equal "gpt-config", client.model
|
|
65
74
|
assert_equal "omni-moderation-test", client.moderation_model
|
|
66
75
|
assert_equal "text-embedding-test", client.embedding_model
|
|
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
|
|
67
79
|
assert_equal 15, client.timeout
|
|
68
80
|
assert_equal 750, client.max_output_tokens
|
|
69
81
|
assert_same client, AiLite.client
|
|
@@ -87,6 +99,9 @@ class AiLiteTest < Minitest::Test
|
|
|
87
99
|
config.model = "gpt-config"
|
|
88
100
|
config.moderation_model = "omni-moderation-config"
|
|
89
101
|
config.embedding_model = "text-embedding-config"
|
|
102
|
+
config.image_model = "gpt-image-config"
|
|
103
|
+
config.speech_model = "gpt-speech-config"
|
|
104
|
+
config.speech_voice = "sage"
|
|
90
105
|
config.timeout = 15
|
|
91
106
|
config.max_output_tokens = 750
|
|
92
107
|
end
|
|
@@ -96,6 +111,9 @@ class AiLiteTest < Minitest::Test
|
|
|
96
111
|
model: "gpt-explicit",
|
|
97
112
|
moderation_model: "omni-moderation-explicit",
|
|
98
113
|
embedding_model: "text-embedding-explicit",
|
|
114
|
+
image_model: "gpt-image-explicit",
|
|
115
|
+
speech_model: "gpt-speech-explicit",
|
|
116
|
+
speech_voice: "coral",
|
|
99
117
|
timeout: 5,
|
|
100
118
|
max_output_tokens: 300
|
|
101
119
|
)
|
|
@@ -104,6 +122,9 @@ class AiLiteTest < Minitest::Test
|
|
|
104
122
|
assert_equal "gpt-explicit", client.model
|
|
105
123
|
assert_equal "omni-moderation-explicit", client.moderation_model
|
|
106
124
|
assert_equal "text-embedding-explicit", client.embedding_model
|
|
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
|
|
107
128
|
assert_equal 5, client.timeout
|
|
108
129
|
assert_equal 300, client.max_output_tokens
|
|
109
130
|
end
|
|
@@ -525,6 +546,245 @@ class AiLiteTest < Minitest::Test
|
|
|
525
546
|
end
|
|
526
547
|
end
|
|
527
548
|
|
|
549
|
+
def test_image_sends_post_to_images_with_default_payload
|
|
550
|
+
client = AiLite.new(api_key: "token-abc")
|
|
551
|
+
image_data = Base64.strict_encode64("fake image")
|
|
552
|
+
|
|
553
|
+
with_stubbed_http(image_response(b64_json: image_data)) do |captured, _response|
|
|
554
|
+
result = client.image("A small ruby gem logo")
|
|
555
|
+
request = captured[:http].last_request
|
|
556
|
+
payload = JSON.parse(request.body)
|
|
557
|
+
|
|
558
|
+
assert_equal image_data, result["content"]
|
|
559
|
+
assert_nil result["response_id"]
|
|
560
|
+
assert_equal 200, result["status"]
|
|
561
|
+
assert_nil result["error"]
|
|
562
|
+
assert_nil result["raw"]
|
|
563
|
+
assert_equal "api.openai.com", captured[:host]
|
|
564
|
+
assert_equal 443, captured[:port]
|
|
565
|
+
assert_equal true, captured[:use_ssl]
|
|
566
|
+
assert_instance_of Net::HTTP::Post, request
|
|
567
|
+
assert_equal "/v1/images/generations", request.path
|
|
568
|
+
assert_equal "Bearer token-abc", request["Authorization"]
|
|
569
|
+
assert_equal "application/json", request["Content-Type"]
|
|
570
|
+
assert_equal "gpt-image-2", payload["model"]
|
|
571
|
+
assert_equal "A small ruby gem logo", payload["prompt"]
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def test_image_includes_options_size_quality_background_output_format_and_model
|
|
576
|
+
client = AiLite.new(api_key: "token-abc")
|
|
577
|
+
|
|
578
|
+
with_stubbed_http(image_response) do |captured, _response|
|
|
579
|
+
client.image(
|
|
580
|
+
"A transparent app icon",
|
|
581
|
+
model: "gpt-image-test",
|
|
582
|
+
size: "1024x1536",
|
|
583
|
+
quality: "high",
|
|
584
|
+
background: "transparent",
|
|
585
|
+
output_format: "webp",
|
|
586
|
+
options: {
|
|
587
|
+
moderation: "auto",
|
|
588
|
+
output_compression: 80
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
payload = JSON.parse(captured[:http].last_request.body)
|
|
592
|
+
|
|
593
|
+
assert_equal "gpt-image-test", payload["model"]
|
|
594
|
+
assert_equal "A transparent app icon", payload["prompt"]
|
|
595
|
+
assert_equal "1024x1536", payload["size"]
|
|
596
|
+
assert_equal "high", payload["quality"]
|
|
597
|
+
assert_equal "transparent", payload["background"]
|
|
598
|
+
assert_equal "webp", payload["output_format"]
|
|
599
|
+
assert_equal "auto", payload["moderation"]
|
|
600
|
+
assert_equal 80, payload["output_compression"]
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def test_image_uses_class_level_configured_client
|
|
605
|
+
AiLite.configure do |config|
|
|
606
|
+
config.api_key = "configured-key"
|
|
607
|
+
config.image_model = "gpt-image-config"
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
with_stubbed_http(image_response) do |captured, _response|
|
|
611
|
+
AiLite.image("Use configured defaults")
|
|
612
|
+
payload = JSON.parse(captured[:http].last_request.body)
|
|
613
|
+
|
|
614
|
+
assert_equal "gpt-image-config", payload["model"]
|
|
615
|
+
assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def test_image_writes_decoded_content_to_output_path
|
|
620
|
+
client = AiLite.new(api_key: "token-abc")
|
|
621
|
+
image_data = Base64.strict_encode64("fake image")
|
|
622
|
+
|
|
623
|
+
Tempfile.create(["generated", ".png"]) do |file|
|
|
624
|
+
with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
|
|
625
|
+
result = client.image("A file output", output_path: file.path)
|
|
626
|
+
|
|
627
|
+
assert_equal image_data, result["content"]
|
|
628
|
+
assert_equal "fake image", File.binread(file.path)
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def test_image_debug_true_returns_raw_usage
|
|
634
|
+
client = AiLite.new(api_key: "token-abc")
|
|
635
|
+
image_data = Base64.strict_encode64("fake image")
|
|
636
|
+
|
|
637
|
+
with_stubbed_http(image_response(b64_json: image_data)) do |_captured, _response|
|
|
638
|
+
result = client.image("A debuggable image", debug: true)
|
|
639
|
+
|
|
640
|
+
assert_equal image_data, result["content"]
|
|
641
|
+
assert_equal({ "input_tokens" => 10, "output_tokens" => 20, "total_tokens" => 30 }, result["raw"]["usage"])
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def test_image_output_path_without_image_data_returns_standard_envelope
|
|
646
|
+
client = AiLite.new(api_key: "token-abc")
|
|
647
|
+
|
|
648
|
+
Tempfile.create(["generated", ".png"]) do |file|
|
|
649
|
+
with_stubbed_http(image_response(b64_json: nil)) do |_captured, _response|
|
|
650
|
+
result = client.image("A missing image", output_path: file.path)
|
|
651
|
+
|
|
652
|
+
assert_nil result["content"]
|
|
653
|
+
assert_nil result["response_id"]
|
|
654
|
+
assert_equal 200, result["status"]
|
|
655
|
+
assert_equal "No image data returned", result["error"]
|
|
656
|
+
assert_nil result["raw"]
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
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
|
+
|
|
528
788
|
def test_http_errors_return_standard_envelope
|
|
529
789
|
client = AiLite.new(api_key: "token-abc")
|
|
530
790
|
body = JSON.generate("error" => { "message" => "Invalid API key" })
|
|
@@ -659,6 +919,30 @@ class AiLiteTest < Minitest::Test
|
|
|
659
919
|
FakeResponse.new("200", body)
|
|
660
920
|
end
|
|
661
921
|
|
|
922
|
+
def image_response(b64_json: Base64.strict_encode64("fake image"))
|
|
923
|
+
image = {}
|
|
924
|
+
image["b64_json"] = b64_json if b64_json
|
|
925
|
+
|
|
926
|
+
body = JSON.generate(
|
|
927
|
+
"created" => 1_713_833_628,
|
|
928
|
+
"background" => "opaque",
|
|
929
|
+
"data" => [image],
|
|
930
|
+
"output_format" => "png",
|
|
931
|
+
"quality" => "medium",
|
|
932
|
+
"size" => "1024x1024",
|
|
933
|
+
"usage" => {
|
|
934
|
+
"input_tokens" => 10,
|
|
935
|
+
"output_tokens" => 20,
|
|
936
|
+
"total_tokens" => 30
|
|
937
|
+
}
|
|
938
|
+
)
|
|
939
|
+
FakeResponse.new("200", body)
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def speech_response(audio = "fake audio")
|
|
943
|
+
FakeResponse.new("200", audio)
|
|
944
|
+
end
|
|
945
|
+
|
|
662
946
|
def with_env(values)
|
|
663
947
|
originals = {}
|
|
664
948
|
|
metadata
CHANGED
|
@@ -1,18 +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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- William Basmayor
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
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.
|
|
15
|
-
email:
|
|
15
|
+
email:
|
|
16
16
|
executables: []
|
|
17
17
|
extensions: []
|
|
18
18
|
extra_rdoc_files: []
|
|
@@ -27,7 +27,7 @@ homepage: https://github.com/wbasmayor/ai-lite
|
|
|
27
27
|
licenses:
|
|
28
28
|
- MIT
|
|
29
29
|
metadata: {}
|
|
30
|
-
post_install_message:
|
|
30
|
+
post_install_message:
|
|
31
31
|
rdoc_options: []
|
|
32
32
|
require_paths:
|
|
33
33
|
- lib
|
|
@@ -42,8 +42,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
42
42
|
- !ruby/object:Gem::Version
|
|
43
43
|
version: '0'
|
|
44
44
|
requirements: []
|
|
45
|
-
rubygems_version: 3.
|
|
46
|
-
signing_key:
|
|
45
|
+
rubygems_version: 3.5.22
|
|
46
|
+
signing_key:
|
|
47
47
|
specification_version: 4
|
|
48
48
|
summary: Minimal Ruby client for simple AI chat calls through the OpenAI Responses
|
|
49
49
|
API.
|