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.
@@ -0,0 +1,607 @@
1
+ require "base64"
2
+ require "json"
3
+ require "net/http"
4
+ require "securerandom"
5
+ require "uri"
6
+ require_relative "version"
7
+ require_relative "openai/configuration"
8
+ require_relative "openai/streaming"
9
+ require_relative "openai/models"
10
+
11
+ class AiLite
12
+ class OpenAI
13
+ class << self
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ reset_client!
21
+ configuration
22
+ end
23
+
24
+ def reset_configuration!
25
+ @configuration = Configuration.new
26
+ reset_client!
27
+ configuration
28
+ end
29
+
30
+ def client
31
+ @client ||= new
32
+ end
33
+
34
+ def chat(message, **kwargs)
35
+ client.chat(message, **kwargs)
36
+ end
37
+
38
+ def chat_stream(message, **kwargs, &block)
39
+ client.chat_stream(message, **kwargs, &block)
40
+ end
41
+
42
+ def moderate(input = nil, **kwargs)
43
+ client.moderate(input, **kwargs)
44
+ end
45
+
46
+ def embed(input, **kwargs)
47
+ client.embed(input, **kwargs)
48
+ end
49
+
50
+ def image(prompt, **kwargs)
51
+ client.image(prompt, **kwargs)
52
+ end
53
+
54
+ def speak(text, **kwargs)
55
+ client.speak(text, **kwargs)
56
+ end
57
+
58
+ def transcribe(file_path, **kwargs)
59
+ client.transcribe(file_path, **kwargs)
60
+ end
61
+
62
+ def reset_client!
63
+ @client = nil
64
+ end
65
+ end
66
+
67
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens, :headers
68
+
69
+ 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)
70
+ @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
71
+ raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
72
+
73
+ @model = model || self.class.configuration.model
74
+ @moderation_model = moderation_model || self.class.configuration.moderation_model
75
+ @embedding_model = embedding_model || self.class.configuration.embedding_model
76
+ @image_model = image_model || self.class.configuration.image_model
77
+ @speech_model = speech_model || self.class.configuration.speech_model
78
+ @speech_voice = speech_voice || self.class.configuration.speech_voice
79
+ @transcription_model = transcription_model || self.class.configuration.transcription_model
80
+ @timeout = timeout || self.class.configuration.timeout
81
+ @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
82
+ @headers = {
83
+ "Authorization" => "Bearer #{@api_key}",
84
+ "Content-Type" => "application/json"
85
+ }
86
+ end
87
+
88
+ def chat(message, model: nil, instructions: nil, previous_response_id: nil, max_output_tokens: nil, debug: false, options: {})
89
+ payload = options.merge(
90
+ model: model || self.model,
91
+ input: message,
92
+ max_output_tokens: max_output_tokens || self.max_output_tokens
93
+ )
94
+ payload[:instructions] = instructions if instructions
95
+ payload[:previous_response_id] = previous_response_id if previous_response_id
96
+
97
+ extract_content(post(payload), debug: debug)
98
+ rescue => e
99
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
100
+ end
101
+
102
+ def moderate(input = nil, model: nil, text: nil, image_url: nil, image_path: nil, debug: false, options: {})
103
+ payload = options.merge(
104
+ model: model || moderation_model,
105
+ input: moderation_input(input, text: text, image_url: image_url, image_path: image_path)
106
+ )
107
+
108
+ extract_moderation(post(payload, endpoint: moderation_endpoint), debug: debug)
109
+ rescue => e
110
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
111
+ end
112
+
113
+ def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
114
+ payload = options.merge(
115
+ model: model || embedding_model,
116
+ input: input
117
+ )
118
+ payload[:dimensions] = dimensions if dimensions
119
+ payload[:encoding_format] = encoding_format if encoding_format
120
+
121
+ extract_embedding(post(payload, endpoint: embedding_endpoint), multiple: input.is_a?(Array), debug: debug)
122
+ rescue => e
123
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
124
+ end
125
+
126
+ def image(prompt, model: nil, size: nil, quality: nil, background: nil, output_format: nil, output_path: nil, debug: false, options: {})
127
+ payload = options.merge(
128
+ model: model || image_model,
129
+ prompt: prompt
130
+ )
131
+ payload[:size] = size if size
132
+ payload[:quality] = quality if quality
133
+ payload[:background] = background if background
134
+ payload[:output_format] = output_format if output_format
135
+
136
+ extract_image(post(payload, endpoint: image_endpoint), output_path: output_path, debug: debug)
137
+ rescue => e
138
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
139
+ end
140
+
141
+ def speak(text, model: nil, voice: nil, response_format: nil, speed: nil, instructions: nil, output_path: nil, base64: false, debug: false, options: {})
142
+ payload = options.merge(
143
+ model: model || speech_model,
144
+ input: text,
145
+ voice: voice || speech_voice
146
+ )
147
+ payload[:response_format] = response_format if response_format
148
+ payload[:speed] = speed if speed
149
+ payload[:instructions] = instructions if instructions
150
+
151
+ extract_speech(
152
+ post(payload, endpoint: speech_endpoint),
153
+ output_path: output_path,
154
+ base64: base64,
155
+ response_format: payload[:response_format] || payload["response_format"] || DEFAULT_SPEECH_FORMAT,
156
+ debug: debug
157
+ )
158
+ rescue => e
159
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
160
+ end
161
+
162
+ def transcribe(file_path, model: nil, language: nil, prompt: nil, response_format: nil, temperature: nil, timestamp_granularities: nil, debug: false, options: {})
163
+ fields = options.merge(
164
+ model: model || transcription_model
165
+ )
166
+ fields[:language] = language if language
167
+ fields[:prompt] = prompt if prompt
168
+ fields[:response_format] = response_format if response_format
169
+ fields[:temperature] = temperature unless temperature.nil?
170
+ fields[:timestamp_granularities] = timestamp_granularities if timestamp_granularities
171
+
172
+ extract_transcription(
173
+ post_multipart(fields, file_field: audio_file_field(file_path), endpoint: transcription_endpoint),
174
+ debug: debug
175
+ )
176
+ rescue => e
177
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
178
+ end
179
+
180
+ private
181
+
182
+ def post(payload, endpoint: response_endpoint)
183
+ uri = URI.parse(endpoint)
184
+ request = Net::HTTP::Post.new(uri)
185
+
186
+ headers.each do |key, value|
187
+ request[key] = value
188
+ end
189
+
190
+ request.body = JSON.generate(payload)
191
+
192
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
193
+ http.open_timeout = timeout if http.respond_to?(:open_timeout=)
194
+ http.read_timeout = timeout if http.respond_to?(:read_timeout=)
195
+ http.request(request)
196
+ end
197
+ end
198
+
199
+ def post_multipart(fields, file_field:, endpoint:)
200
+ uri = URI.parse(endpoint)
201
+ boundary = "----AiLiteBoundary#{SecureRandom.hex(16)}"
202
+ request = Net::HTTP::Post.new(uri)
203
+ request["Authorization"] = headers["Authorization"]
204
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
205
+ request.body = multipart_body(fields, file_field: file_field, boundary: boundary)
206
+
207
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
208
+ http.open_timeout = timeout if http.respond_to?(:open_timeout=)
209
+ http.read_timeout = timeout if http.respond_to?(:read_timeout=)
210
+ http.request(request)
211
+ end
212
+ end
213
+
214
+ def response_endpoint
215
+ "#{API_BASE_URL}/responses"
216
+ end
217
+
218
+ def moderation_endpoint
219
+ "#{API_BASE_URL}/moderations"
220
+ end
221
+
222
+ def embedding_endpoint
223
+ "#{API_BASE_URL}/embeddings"
224
+ end
225
+
226
+ def image_endpoint
227
+ "#{API_BASE_URL}/images/generations"
228
+ end
229
+
230
+ def speech_endpoint
231
+ "#{API_BASE_URL}/audio/speech"
232
+ end
233
+
234
+ def transcription_endpoint
235
+ "#{API_BASE_URL}/audio/transcriptions"
236
+ end
237
+
238
+ def extract_content(response, debug: false)
239
+ status = response.code.to_i
240
+ parsed_response = JSON.parse(response.body)
241
+
242
+ unless success_status?(status)
243
+ return prettify_data(
244
+ status: status,
245
+ error: error_message(parsed_response),
246
+ response_id: parsed_response["id"],
247
+ raw: parsed_response,
248
+ debug: debug
249
+ )
250
+ end
251
+
252
+ raw_content = extract_output_text(parsed_response)
253
+ content = parse_content(raw_content)
254
+ error = if parsed_response["error"]
255
+ error_message(parsed_response)
256
+ elsif %w[failed cancelled incomplete].include?(parsed_response["status"])
257
+ reason = parsed_response.dig("incomplete_details", "reason")
258
+ ["Response #{parsed_response['status']}", reason].compact.join(": ")
259
+ end
260
+ prettify_data(
261
+ status: status,
262
+ content: content,
263
+ error: error,
264
+ response_id: parsed_response["id"],
265
+ raw: parsed_response,
266
+ debug: debug
267
+ )
268
+ rescue JSON::ParserError => e
269
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
270
+ rescue => e
271
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
272
+ end
273
+
274
+ def extract_moderation(response, debug: false)
275
+ status = response.code.to_i
276
+ parsed_response = JSON.parse(response.body)
277
+
278
+ unless success_status?(status)
279
+ return prettify_data(
280
+ status: status,
281
+ error: error_message(parsed_response),
282
+ response_id: parsed_response["id"],
283
+ raw: parsed_response,
284
+ debug: debug
285
+ )
286
+ end
287
+
288
+ prettify_data(
289
+ status: status,
290
+ content: moderation_content(parsed_response),
291
+ response_id: parsed_response["id"],
292
+ raw: parsed_response,
293
+ debug: debug
294
+ )
295
+ rescue JSON::ParserError => e
296
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
297
+ rescue => e
298
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
299
+ end
300
+
301
+ def extract_embedding(response, multiple:, debug: false)
302
+ status = response.code.to_i
303
+ parsed_response = JSON.parse(response.body)
304
+
305
+ unless success_status?(status)
306
+ return prettify_data(
307
+ status: status,
308
+ error: error_message(parsed_response),
309
+ response_id: parsed_response["id"],
310
+ raw: parsed_response,
311
+ debug: debug
312
+ )
313
+ end
314
+
315
+ prettify_data(
316
+ status: status,
317
+ content: embedding_content(parsed_response, multiple: multiple),
318
+ response_id: parsed_response["id"],
319
+ raw: parsed_response,
320
+ debug: debug
321
+ )
322
+ rescue JSON::ParserError => e
323
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
324
+ rescue => e
325
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
326
+ end
327
+
328
+ def extract_image(response, output_path:, debug: false)
329
+ status = response.code.to_i
330
+ parsed_response = JSON.parse(response.body)
331
+
332
+ unless success_status?(status)
333
+ return prettify_data(
334
+ status: status,
335
+ error: error_message(parsed_response),
336
+ response_id: parsed_response["id"],
337
+ raw: parsed_response,
338
+ debug: debug
339
+ )
340
+ end
341
+
342
+ content = image_content(parsed_response)
343
+ write_image_output(output_path, content) if output_path
344
+
345
+ prettify_data(
346
+ status: status,
347
+ content: content,
348
+ response_id: parsed_response["id"],
349
+ raw: parsed_response,
350
+ debug: debug
351
+ )
352
+ rescue JSON::ParserError => e
353
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
354
+ rescue => e
355
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
356
+ end
357
+
358
+ def extract_speech(response, output_path:, base64:, response_format:, debug: false)
359
+ status = response.code.to_i
360
+
361
+ unless success_status?(status)
362
+ parsed_response = parse_error_response(response.body)
363
+
364
+ return prettify_data(
365
+ status: status,
366
+ error: error_message(parsed_response),
367
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
368
+ raw: parsed_response,
369
+ debug: debug
370
+ )
371
+ end
372
+
373
+ audio = response.body
374
+ File.binwrite(output_path, audio) if output_path
375
+
376
+ prettify_data(
377
+ status: status,
378
+ content: speech_content(audio, output_path: output_path, base64: base64, response_format: response_format),
379
+ response_id: nil,
380
+ raw: audio,
381
+ debug: debug
382
+ )
383
+ rescue => e
384
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
385
+ end
386
+
387
+ def extract_transcription(response, debug: false)
388
+ status = response.code.to_i
389
+ parsed_response = parse_error_response(response.body)
390
+
391
+ unless success_status?(status)
392
+ return prettify_data(
393
+ status: status,
394
+ error: error_message(parsed_response),
395
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
396
+ raw: parsed_response,
397
+ debug: debug
398
+ )
399
+ end
400
+
401
+ prettify_data(
402
+ status: status,
403
+ content: transcription_content(parsed_response),
404
+ response_id: parsed_response.is_a?(Hash) ? parsed_response["id"] : nil,
405
+ raw: parsed_response,
406
+ debug: debug
407
+ )
408
+ rescue => e
409
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
410
+ end
411
+
412
+ def extract_output_text(raw)
413
+ Array(raw["output"]).flat_map do |item|
414
+ next [] unless item.is_a?(Hash) && item["type"] == "message"
415
+
416
+ Array(item["content"]).map do |content|
417
+ next unless content.is_a?(Hash) && content["type"] == "output_text"
418
+
419
+ content["text"]
420
+ end.compact
421
+ end.join.strip
422
+ end
423
+
424
+ def parse_content(raw_text)
425
+ return nil if raw_text.nil? || raw_text.empty?
426
+
427
+ JSON.parse(raw_text)
428
+ rescue JSON::ParserError
429
+ raw_text
430
+ end
431
+
432
+ def moderation_input(input, text:, image_url:, image_path:)
433
+ unless text || image_url || image_path
434
+ raise ArgumentError, "Missing moderation input" if input.nil?
435
+
436
+ return input
437
+ end
438
+
439
+ items = []
440
+ items.concat(Array(input).map { |value| moderation_input_item(value) }) unless input.nil?
441
+ items << { type: "text", text: text } if text
442
+ items << { type: "image_url", image_url: { url: image_url } } if image_url
443
+ items << { type: "image_url", image_url: { url: image_data_url(image_path) } } if image_path
444
+ raise ArgumentError, "Missing moderation input" if items.empty?
445
+
446
+ items
447
+ end
448
+
449
+ def moderation_input_item(value)
450
+ case value
451
+ when String
452
+ { type: "text", text: value }
453
+ when Hash
454
+ value
455
+ else
456
+ raise ArgumentError, "Unsupported moderation input item: #{value.class}"
457
+ end
458
+ end
459
+
460
+ def image_data_url(path)
461
+ mime_type = image_mime_type(path)
462
+ "data:#{mime_type};base64,#{Base64.strict_encode64(File.binread(path))}"
463
+ end
464
+
465
+ def image_mime_type(path)
466
+ IMAGE_MIME_TYPES.fetch(File.extname(path).downcase) do
467
+ raise ArgumentError, "Unsupported image type for moderation: #{File.extname(path)}"
468
+ end
469
+ end
470
+
471
+ def audio_file_field(path)
472
+ raise ArgumentError, "Audio file not found: #{path}" unless File.file?(path)
473
+
474
+ {
475
+ name: "file",
476
+ path: path,
477
+ filename: File.basename(path),
478
+ content_type: audio_mime_type(path)
479
+ }
480
+ end
481
+
482
+ def audio_mime_type(path)
483
+ extension = File.extname(path).downcase
484
+ AUDIO_MIME_TYPES.fetch(extension) do
485
+ raise ArgumentError, "Unsupported audio type for transcription: #{extension}"
486
+ end
487
+ end
488
+
489
+ def moderation_content(raw)
490
+ results = raw["results"]
491
+ return nil unless results.is_a?(Array)
492
+
493
+ results.length == 1 ? results.first : results
494
+ end
495
+
496
+ def embedding_content(raw, multiple:)
497
+ embeddings = Array(raw["data"]).map do |item|
498
+ item["embedding"] if item.is_a?(Hash)
499
+ end.compact
500
+
501
+ multiple ? embeddings : embeddings.first
502
+ end
503
+
504
+ def image_content(raw)
505
+ image = Array(raw["data"]).find { |item| item.is_a?(Hash) && item["b64_json"] }
506
+ image && image["b64_json"]
507
+ end
508
+
509
+ def write_image_output(path, content)
510
+ raise "No image data returned" if content.to_s.empty?
511
+
512
+ File.binwrite(path, Base64.decode64(content))
513
+ end
514
+
515
+ def speech_content(audio, output_path:, base64:, response_format:)
516
+ return Base64.strict_encode64(audio) if base64
517
+ return audio unless output_path
518
+
519
+ {
520
+ "path" => output_path,
521
+ "bytes" => audio.bytesize,
522
+ "format" => response_format
523
+ }
524
+ end
525
+
526
+ def transcription_content(raw)
527
+ return raw["text"] if raw.is_a?(Hash) && raw.key?("text")
528
+
529
+ raw
530
+ end
531
+
532
+ def multipart_body(fields, file_field:, boundary:)
533
+ body = String.new(encoding: Encoding::BINARY)
534
+
535
+ fields.each do |name, value|
536
+ multipart_field_parts(name, value).each do |field_name, field_value|
537
+ body << "--#{boundary}\r\n".b
538
+ body << "Content-Disposition: form-data; name=\"#{multipart_quote(field_name)}\"\r\n\r\n".b
539
+ body << field_value.to_s.b
540
+ body << "\r\n".b
541
+ end
542
+ end
543
+
544
+ body << "--#{boundary}\r\n".b
545
+ body << "Content-Disposition: form-data; name=\"#{multipart_quote(file_field[:name])}\"; filename=\"#{multipart_quote(file_field[:filename])}\"\r\n".b
546
+ body << "Content-Type: #{file_field[:content_type]}\r\n\r\n".b
547
+ body << File.binread(file_field[:path])
548
+ body << "\r\n--#{boundary}--\r\n".b
549
+ body
550
+ end
551
+
552
+ def multipart_field_parts(name, value)
553
+ return [] if value.nil?
554
+
555
+ if value.is_a?(Array)
556
+ value.map { |item| ["#{name}[]", multipart_value(item)] }
557
+ else
558
+ [[name.to_s, multipart_value(value)]]
559
+ end
560
+ end
561
+
562
+ def multipart_value(value)
563
+ case value
564
+ when Hash
565
+ JSON.generate(value)
566
+ else
567
+ value
568
+ end
569
+ end
570
+
571
+ def multipart_quote(value)
572
+ value.to_s.gsub("\\", "\\\\").gsub("\"", "\\\"").delete("\r\n")
573
+ end
574
+
575
+ def parse_error_response(body)
576
+ JSON.parse(body)
577
+ rescue JSON::ParserError
578
+ body
579
+ end
580
+
581
+ def success_status?(status)
582
+ status >= 200 && status < 300
583
+ end
584
+
585
+ def error_message(raw)
586
+ if raw.is_a?(Hash)
587
+ raw.dig("error", "message") || raw["error"] || raw["message"] || raw.to_s
588
+ else
589
+ raw.to_s
590
+ end
591
+ end
592
+
593
+ def response_status(response)
594
+ response&.code&.to_i || "unknown"
595
+ end
596
+
597
+ def prettify_data(status:, content: nil, error: nil, response_id: nil, raw:, debug: false)
598
+ {
599
+ "content" => content,
600
+ "response_id" => response_id,
601
+ "status" => status,
602
+ "error" => error,
603
+ "raw" => debug ? raw : nil
604
+ }
605
+ end
606
+ end
607
+ end
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.6.0".freeze
2
+ VERSION = "1.0.0".freeze
3
3
  end