ai-lite 0.6.0 → 1.0.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.
data/lib/ai_lite.rb CHANGED
@@ -1,637 +1,46 @@
1
- require "base64"
2
- require "json"
3
- require "net/http"
4
- require "securerandom"
5
- require "uri"
6
1
  require_relative "ai_lite/version"
2
+ require_relative "ai_lite/openai"
3
+ require_relative "ai_lite/gemini"
7
4
 
8
5
  class AiLite
9
- API_BASE_URL = "https://api.openai.com/v1".freeze
10
- DEFAULT_MODEL = "gpt-5.5".freeze
11
- DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
12
- DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
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
18
- DEFAULT_TIMEOUT = 120
19
- DEFAULT_MAX_OUTPUT_TOKENS = 2000
20
- IMAGE_MIME_TYPES = {
21
- ".gif" => "image/gif",
22
- ".jpeg" => "image/jpeg",
23
- ".jpg" => "image/jpeg",
24
- ".png" => "image/png",
25
- ".webp" => "image/webp"
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
38
-
39
- class Configuration
40
- attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens
41
-
42
- def initialize
43
- @api_key = nil
44
- @model = DEFAULT_MODEL
45
- @moderation_model = DEFAULT_MODERATION_MODEL
46
- @embedding_model = DEFAULT_EMBEDDING_MODEL
47
- @image_model = DEFAULT_IMAGE_MODEL
48
- @speech_model = DEFAULT_SPEECH_MODEL
49
- @speech_voice = DEFAULT_SPEECH_VOICE
50
- @transcription_model = DEFAULT_TRANSCRIPTION_MODEL
51
- @timeout = DEFAULT_TIMEOUT
52
- @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
53
- end
54
- end
55
-
56
- class << self
57
- def configuration
58
- @configuration ||= Configuration.new
59
- end
60
-
61
- def configure
62
- yield(configuration)
63
- reset_client!
64
- configuration
65
- end
66
-
67
- def reset_configuration!
68
- @configuration = Configuration.new
69
- reset_client!
70
- configuration
71
- end
72
-
73
- def client
74
- @client ||= new
75
- end
76
-
77
- def chat(message, **kwargs)
78
- client.chat(message, **kwargs)
79
- end
80
-
81
- def moderate(input = nil, **kwargs)
82
- client.moderate(input, **kwargs)
83
- end
84
-
85
- def embed(input, **kwargs)
86
- client.embed(input, **kwargs)
87
- end
88
-
89
- def image(prompt, **kwargs)
90
- client.image(prompt, **kwargs)
91
- end
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
-
101
- def reset_client!
102
- @client = nil
103
- end
104
- end
105
-
106
- attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens, :headers
107
-
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)
109
- @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
110
- raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
111
-
112
- @model = model || self.class.configuration.model
113
- @moderation_model = moderation_model || self.class.configuration.moderation_model
114
- @embedding_model = embedding_model || self.class.configuration.embedding_model
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
119
- @timeout = timeout || self.class.configuration.timeout
120
- @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
121
- @headers = {
122
- "Authorization" => "Bearer #{@api_key}",
123
- "Content-Type" => "application/json"
124
- }
125
- end
126
-
127
- def chat(message, model: nil, instructions: nil, max_output_tokens: nil, debug: false, options: {})
128
- payload = options.merge(
129
- model: model || self.model,
130
- input: message,
131
- max_output_tokens: max_output_tokens || self.max_output_tokens
132
- )
133
- payload[:instructions] = instructions if instructions
134
-
135
- extract_content(post(payload), debug: debug)
136
- rescue => e
137
- prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
138
- end
139
-
140
- def moderate(input = nil, model: nil, text: nil, image_url: nil, image_path: nil, debug: false, options: {})
141
- payload = options.merge(
142
- model: model || moderation_model,
143
- input: moderation_input(input, text: text, image_url: image_url, image_path: image_path)
144
- )
145
-
146
- extract_moderation(post(payload, endpoint: moderation_endpoint), debug: debug)
147
- rescue => e
148
- prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
149
- end
150
-
151
- def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
152
- payload = options.merge(
153
- model: model || embedding_model,
154
- input: input
155
- )
156
- payload[:dimensions] = dimensions if dimensions
157
- payload[:encoding_format] = encoding_format if encoding_format
158
-
159
- extract_embedding(post(payload, endpoint: embedding_endpoint), multiple: input.is_a?(Array), debug: debug)
160
- rescue => e
161
- prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
162
- end
163
-
164
- def image(prompt, model: nil, size: nil, quality: nil, background: nil, output_format: nil, output_path: nil, debug: false, options: {})
165
- payload = options.merge(
166
- model: model || image_model,
167
- prompt: prompt
168
- )
169
- payload[:size] = size if size
170
- payload[:quality] = quality if quality
171
- payload[:background] = background if background
172
- payload[:output_format] = output_format if output_format
173
-
174
- extract_image(post(payload, endpoint: image_endpoint), output_path: output_path, debug: debug)
175
- rescue => e
176
- prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
6
+ # Preserve previously public constants while ownership stays with OpenAI.
7
+ OpenAI.constants(false).each do |name|
8
+ const_set(name, OpenAI.const_get(name))
177
9
  end
178
10
 
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
-
218
- private
219
-
220
- def post(payload, endpoint: response_endpoint)
221
- uri = URI.parse(endpoint)
222
- request = Net::HTTP::Post.new(uri)
223
-
224
- headers.each do |key, value|
225
- request[key] = value
226
- end
227
-
228
- request.body = JSON.generate(payload)
229
-
230
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
231
- http.open_timeout = timeout if http.respond_to?(:open_timeout=)
232
- http.read_timeout = timeout if http.respond_to?(:read_timeout=)
233
- http.request(request)
234
- end
235
- end
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
-
252
- def response_endpoint
253
- "#{API_BASE_URL}/responses"
254
- end
255
-
256
- def moderation_endpoint
257
- "#{API_BASE_URL}/moderations"
258
- end
259
-
260
- def embedding_endpoint
261
- "#{API_BASE_URL}/embeddings"
262
- end
263
-
264
- def image_endpoint
265
- "#{API_BASE_URL}/images/generations"
266
- end
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
-
276
- def extract_content(response, debug: false)
277
- status = response.code.to_i
278
- parsed_response = JSON.parse(response.body)
279
-
280
- unless success_status?(status)
281
- return prettify_data(
282
- status: status,
283
- error: error_message(parsed_response),
284
- response_id: parsed_response["id"],
285
- raw: parsed_response,
286
- debug: debug
287
- )
288
- end
289
-
290
- raw_content = extract_output_text(parsed_response)
291
- content = parse_content(raw_content)
292
- prettify_data(
293
- status: status,
294
- content: content,
295
- response_id: parsed_response["id"],
296
- raw: parsed_response,
297
- debug: debug
298
- )
299
- rescue JSON::ParserError => e
300
- prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
301
- rescue => e
302
- prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
303
- end
304
-
305
- def extract_moderation(response, debug: false)
306
- status = response.code.to_i
307
- parsed_response = JSON.parse(response.body)
308
-
309
- unless success_status?(status)
310
- return prettify_data(
311
- status: status,
312
- error: error_message(parsed_response),
313
- response_id: parsed_response["id"],
314
- raw: parsed_response,
315
- debug: debug
316
- )
317
- end
318
-
319
- prettify_data(
320
- status: status,
321
- content: moderation_content(parsed_response),
322
- response_id: parsed_response["id"],
323
- raw: parsed_response,
324
- debug: debug
325
- )
326
- rescue JSON::ParserError => e
327
- prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
328
- rescue => e
329
- prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
330
- end
331
-
332
- def extract_embedding(response, multiple:, debug: false)
333
- status = response.code.to_i
334
- parsed_response = JSON.parse(response.body)
335
-
336
- unless success_status?(status)
337
- return prettify_data(
338
- status: status,
339
- error: error_message(parsed_response),
340
- response_id: parsed_response["id"],
341
- raw: parsed_response,
342
- debug: debug
343
- )
344
- end
11
+ @deprecation_mutex = Mutex.new
12
+ @deprecated_entry_points = {}
345
13
 
346
- prettify_data(
347
- status: status,
348
- content: embedding_content(parsed_response, multiple: multiple),
349
- response_id: parsed_response["id"],
350
- raw: parsed_response,
351
- debug: debug
352
- )
353
- rescue JSON::ParserError => e
354
- prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
355
- rescue => e
356
- prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
357
- end
358
-
359
- def extract_image(response, output_path:, debug: false)
360
- status = response.code.to_i
361
- parsed_response = JSON.parse(response.body)
362
-
363
- unless success_status?(status)
364
- return prettify_data(
365
- status: status,
366
- error: error_message(parsed_response),
367
- response_id: parsed_response["id"],
368
- raw: parsed_response,
369
- debug: debug
370
- )
371
- end
372
-
373
- content = image_content(parsed_response)
374
- write_image_output(output_path, content) if output_path
375
-
376
- prettify_data(
377
- status: status,
378
- content: content,
379
- response_id: parsed_response["id"],
380
- raw: parsed_response,
381
- debug: debug
382
- )
383
- rescue JSON::ParserError => e
384
- prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
385
- rescue => e
386
- prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
387
- end
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
-
443
- def extract_output_text(raw)
444
- Array(raw["output"]).flat_map do |item|
445
- next [] unless item.is_a?(Hash) && item["type"] == "message"
446
-
447
- Array(item["content"]).map do |content|
448
- next unless content.is_a?(Hash) && content["type"] == "output_text"
449
-
450
- content["text"]
451
- end.compact
452
- end.join.strip
453
- end
454
-
455
- def parse_content(raw_text)
456
- return nil if raw_text.nil? || raw_text.empty?
457
-
458
- JSON.parse(raw_text)
459
- rescue JSON::ParserError
460
- raw_text
461
- end
462
-
463
- def moderation_input(input, text:, image_url:, image_path:)
464
- unless text || image_url || image_path
465
- raise ArgumentError, "Missing moderation input" if input.nil?
466
-
467
- return input
468
- end
469
-
470
- items = []
471
- items.concat(Array(input).map { |value| moderation_input_item(value) }) unless input.nil?
472
- items << { type: "text", text: text } if text
473
- items << { type: "image_url", image_url: { url: image_url } } if image_url
474
- items << { type: "image_url", image_url: { url: image_data_url(image_path) } } if image_path
475
- raise ArgumentError, "Missing moderation input" if items.empty?
476
-
477
- items
478
- end
479
-
480
- def moderation_input_item(value)
481
- case value
482
- when String
483
- { type: "text", text: value }
484
- when Hash
485
- value
486
- else
487
- raise ArgumentError, "Unsupported moderation input item: #{value.class}"
488
- end
489
- end
490
-
491
- def image_data_url(path)
492
- mime_type = image_mime_type(path)
493
- "data:#{mime_type};base64,#{Base64.strict_encode64(File.binread(path))}"
494
- end
495
-
496
- def image_mime_type(path)
497
- IMAGE_MIME_TYPES.fetch(File.extname(path).downcase) do
498
- raise ArgumentError, "Unsupported image type for moderation: #{File.extname(path)}"
499
- end
500
- end
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
-
520
- def moderation_content(raw)
521
- results = raw["results"]
522
- return nil unless results.is_a?(Array)
523
-
524
- results.length == 1 ? results.first : results
525
- end
526
-
527
- def embedding_content(raw, multiple:)
528
- embeddings = Array(raw["data"]).map do |item|
529
- item["embedding"] if item.is_a?(Hash)
530
- end.compact
531
-
532
- multiple ? embeddings : embeddings.first
533
- end
534
-
535
- def image_content(raw)
536
- image = Array(raw["data"]).find { |item| item.is_a?(Hash) && item["b64_json"] }
537
- image && image["b64_json"]
538
- end
539
-
540
- def write_image_output(path, content)
541
- raise "No image data returned" if content.to_s.empty?
542
-
543
- File.binwrite(path, Base64.decode64(content))
544
- end
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
14
+ class << self
15
+ def new(**kwargs)
16
+ warn_legacy_entry_point(:new)
17
+ OpenAI.new(**kwargs)
18
+ end
19
+
20
+ %i[configuration configure reset_configuration! client reset_client!
21
+ chat chat_stream moderate embed image speak transcribe
22
+ available_models model_available? configured_models check_configured_models].each do |method_name|
23
+ define_method(method_name) do |*args, **kwargs, &block|
24
+ warn_legacy_entry_point(method_name)
25
+ if kwargs.empty?
26
+ OpenAI.public_send(method_name, *args, &block)
27
+ else
28
+ OpenAI.public_send(method_name, *args, **kwargs, &block)
29
+ end
572
30
  end
573
31
  end
574
32
 
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
33
+ private
582
34
 
583
- def multipart_field_parts(name, value)
584
- return [] if value.nil?
35
+ def warn_legacy_entry_point(method_name)
36
+ @deprecation_mutex.synchronize do
37
+ return if @deprecated_entry_points[method_name]
585
38
 
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
-
612
- def success_status?(status)
613
- status >= 200 && status < 300
614
- end
615
-
616
- def error_message(raw)
617
- if raw.is_a?(Hash)
618
- raw.dig("error", "message") || raw["error"] || raw["message"] || raw.to_s
619
- else
620
- raw.to_s
39
+ warn "[ai-lite] AiLite.#{method_name} is deprecated. " \
40
+ "Use AiLite::OpenAI.#{method_name} instead. " \
41
+ "The legacy entry point will be removed in v2.0."
42
+ @deprecated_entry_points[method_name] = true
43
+ end
621
44
  end
622
45
  end
623
-
624
- def response_status(response)
625
- response&.code&.to_i || "unknown"
626
- end
627
-
628
- def prettify_data(status:, content: nil, error: nil, response_id: nil, raw:, debug: false)
629
- {
630
- "content" => content,
631
- "response_id" => response_id,
632
- "status" => status,
633
- "error" => error,
634
- "raw" => debug ? raw : nil
635
- }
636
- end
637
46
  end