ai-lite 0.6.1 → 1.1.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,23 @@
1
+ class AiLite
2
+ class Gemini
3
+ API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta".freeze
4
+ DEFAULT_MODEL = "gemini-3.8-flash".freeze
5
+ DEFAULT_EMBEDDING_MODEL = "gemini-embedding-001".freeze
6
+ DEFAULT_IMAGE_MODEL = "gemini-nano-banana-2.1".freeze
7
+ DEFAULT_TIMEOUT = 120
8
+ DEFAULT_MAX_OUTPUT_TOKENS = 2000
9
+
10
+ class Configuration
11
+ attr_accessor :api_key, :model, :embedding_model, :image_model, :timeout, :max_output_tokens
12
+
13
+ def initialize
14
+ @api_key = nil
15
+ @model = DEFAULT_MODEL
16
+ @embedding_model = DEFAULT_EMBEDDING_MODEL
17
+ @image_model = DEFAULT_IMAGE_MODEL
18
+ @timeout = DEFAULT_TIMEOUT
19
+ @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,58 @@
1
+ class AiLite
2
+ class Gemini
3
+ module Embeddings
4
+ def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
5
+ response = nil
6
+ raw = nil
7
+ multiple = input.is_a?(Array)
8
+ texts = multiple ? input : [input]
9
+ unless !texts.empty? && texts.all? { |text| text.is_a?(String) && !text.strip.empty? }
10
+ raise ArgumentError, "input must be a nonempty string or array of nonempty strings"
11
+ end
12
+ unless dimensions.nil? || (dimensions.is_a?(Integer) && dimensions > 0)
13
+ raise ArgumentError, "dimensions must be a positive integer"
14
+ end
15
+ unless encoding_format.nil? || encoding_format == "float"
16
+ raise ArgumentError, "Gemini embeddings only support encoding_format: float"
17
+ end
18
+ model_id = model || embedding_model
19
+ unless model_id.is_a?(String) && model_id.match?(%r{\A(?:models/)?[A-Za-z0-9._-]+\z})
20
+ raise ArgumentError, "model must be a Gemini model ID"
21
+ end
22
+ resource = "models/#{model_id.delete_prefix('models/')}"
23
+ settings = options.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
24
+ settings["outputDimensionality"] = dimensions if dimensions
25
+ requests = texts.map do |text|
26
+ settings.merge("model" => resource, "content" => { "parts" => [{ "text" => text }] })
27
+ end
28
+ action = multiple ? "batchEmbedContents" : "embedContent"
29
+ payload = multiple ? { "requests" => requests } : requests.first
30
+ response = post_json(payload, endpoint: "#{API_BASE_URL}/#{resource}:#{action}")
31
+ raw = response.body
32
+ raw = JSON.parse(raw)
33
+ raise "Invalid Gemini embedding response: expected an object" unless raw.is_a?(Hash)
34
+ if !response.code.to_i.between?(200, 299) || raw["error"]
35
+ return result_envelope(status: response.code.to_i, error: error_message(raw), raw: raw, debug: debug)
36
+ end
37
+ embeddings = multiple ? raw["embeddings"] : [raw["embedding"]]
38
+ unless embeddings.is_a?(Array) && embeddings.length == texts.length
39
+ raise "Invalid Gemini embedding response: unexpected embedding count"
40
+ end
41
+ vectors = embeddings.map do |entry|
42
+ values = entry["values"] if entry.is_a?(Hash)
43
+ unless values.is_a?(Array) && !values.empty? && values.all? { |value| value.is_a?(Numeric) }
44
+ raise "Invalid Gemini embedding response: expected numeric values"
45
+ end
46
+ values
47
+ end
48
+ result_envelope(status: response.code.to_i, content: multiple ? vectors : vectors.first,
49
+ raw: raw, debug: debug)
50
+ rescue StandardError => e
51
+ result_envelope(status: response ? response.code.to_i : "unknown", error: e.message, raw: raw, debug: debug)
52
+ end
53
+ end
54
+
55
+ include Embeddings
56
+ private_constant :Embeddings
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ class AiLite
2
+ class Gemini
3
+ module Images
4
+ def image(prompt, model: nil, output_path: nil, debug: false, options: {})
5
+ response = nil
6
+ raw = nil
7
+ response_id = nil
8
+ unless prompt.is_a?(String) && !prompt.strip.empty?
9
+ raise ArgumentError, "prompt must be a nonempty string"
10
+ end
11
+ payload = options.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
12
+ if payload["stream"] || payload["background"]
13
+ raise ArgumentError, "image does not support streaming or background execution"
14
+ end
15
+ payload.merge!("model" => model || image_model, "input" => prompt)
16
+ response = post_interaction(payload)
17
+ raw = response.body
18
+ raw = JSON.parse(raw)
19
+ raise "Invalid Gemini image response: expected an object" unless raw.is_a?(Hash)
20
+
21
+ response_id = raw["id"]
22
+ if !response.code.to_i.between?(200, 299) || raw["error"]
23
+ return result_envelope(status: response.code.to_i, error: error_message(raw),
24
+ response_id: response_id, raw: raw, debug: debug)
25
+ end
26
+ unless raw["status"] == "completed"
27
+ raise "Gemini interaction #{raw['status'] || 'has no status'}; inspect raw with debug: true"
28
+ end
29
+ steps = raw["steps"]
30
+ raise "Invalid Gemini image response: expected steps" unless steps.is_a?(Array)
31
+
32
+ image = steps.flat_map do |step|
33
+ next [] unless step.is_a?(Hash) && step["type"] == "model_output"
34
+
35
+ step["content"].is_a?(Array) ? step["content"] : []
36
+ end.find { |part| part.is_a?(Hash) && part["type"] == "image" }
37
+ content = image && image["data"]
38
+ unless content.is_a?(String) && !content.empty?
39
+ raise "No inline image data returned; inspect raw with debug: true"
40
+ end
41
+ bytes = Base64.strict_decode64(content)
42
+ raise "Empty image data returned" if bytes.empty?
43
+
44
+ File.binwrite(output_path, bytes) if output_path
45
+ result_envelope(status: response.code.to_i, content: content, response_id: response_id,
46
+ raw: raw, debug: debug)
47
+ rescue StandardError => e
48
+ result_envelope(status: response ? response.code.to_i : "unknown", error: e.message,
49
+ response_id: response_id, raw: raw, debug: debug)
50
+ end
51
+ end
52
+
53
+ include Images
54
+ private_constant :Images
55
+ end
56
+ end
@@ -0,0 +1,104 @@
1
+ class AiLite
2
+ class Gemini
3
+ module Models
4
+ def self.snapshot(source)
5
+ { "chat" => source.model, "embedding" => source.embedding_model, "image" => source.image_model }.transform_values do |model|
6
+ model.is_a?(String) ? model.dup : model
7
+ end
8
+ end
9
+
10
+ def configured_models
11
+ Models.snapshot(self)
12
+ end
13
+
14
+ def available_models(debug: false)
15
+ status = "unknown"
16
+ pages = []
17
+ ids = []
18
+ tokens = []
19
+ token = nil
20
+ loop do
21
+ status = "unknown"
22
+ uri = URI.parse("#{API_BASE_URL}/models")
23
+ uri.query = URI.encode_www_form(pageToken: token) if token
24
+ request = Net::HTTP::Get.new(uri)
25
+ headers.each { |key, value| request[key] = value }
26
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
27
+ http.open_timeout = timeout
28
+ http.read_timeout = timeout
29
+ http.request(request)
30
+ end
31
+ status = response.code.to_i
32
+ pages << response.body if debug
33
+ page = JSON.parse(response.body)
34
+ pages[-1] = page if debug
35
+ if !status.between?(200, 299) || (page.is_a?(Hash) && page["error"])
36
+ return result_envelope(status: status, error: error_message(page), raw: { "pages" => pages }, debug: debug)
37
+ end
38
+ # Protobuf JSON may omit an empty repeated field (models).
39
+ unless page.is_a?(Hash) && page.fetch("models", []).is_a?(Array)
40
+ raise "Invalid Gemini model list response"
41
+ end
42
+ page.fetch("models", []).each do |entry|
43
+ unless entry.is_a?(Hash) && entry["name"].is_a?(String) && entry["name"].match?(%r{\Amodels/[^/\s]+\z})
44
+ raise "Invalid Gemini model record"
45
+ end
46
+ ids << entry["name"].delete_prefix("models/")
47
+ end
48
+ token = page["nextPageToken"]
49
+ break if token.nil? || token == ""
50
+ raise "Invalid Gemini pagination token" unless token.is_a?(String)
51
+ raise "Repeated Gemini pagination token" if tokens.include?(token)
52
+ tokens << token
53
+ end
54
+ result_envelope(status: status, content: ids.uniq, raw: { "pages" => pages }, debug: debug)
55
+ rescue StandardError => e
56
+ result_envelope(status: status, error: e.message, raw: { "pages" => pages }, debug: debug)
57
+ end
58
+
59
+ def model_available?(model_id)
60
+ unless model_id.is_a?(String) && !model_id.strip.empty?
61
+ raise ArgumentError, "model_id must be a nonempty string"
62
+ end
63
+ result = available_models
64
+ return nil if result["error"]
65
+
66
+ result["content"].include?(model_id.delete_prefix("models/"))
67
+ end
68
+
69
+ def check_configured_models(debug: false)
70
+ configured = configured_models
71
+ result = available_models(debug: debug)
72
+ report = configured.each_with_object({}) do |(usage, model), entries|
73
+ entries[usage] = {
74
+ "model" => model, "usage" => usage,
75
+ "available" => result["error"] ? nil : result["content"].include?(model.to_s.delete_prefix("models/")),
76
+ "error" => result["error"]
77
+ }
78
+ end
79
+ result.merge("content" => report)
80
+ end
81
+ end
82
+
83
+ include Models
84
+ private_constant :Models
85
+
86
+ class << self
87
+ def configured_models
88
+ Models.snapshot(configuration)
89
+ end
90
+
91
+ def available_models(**kwargs)
92
+ client.available_models(**kwargs)
93
+ end
94
+
95
+ def model_available?(model_id)
96
+ client.model_available?(model_id)
97
+ end
98
+
99
+ def check_configured_models(**kwargs)
100
+ client.check_configured_models(**kwargs)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,148 @@
1
+ class AiLite
2
+ class Gemini
3
+ module Streaming
4
+ def chat_stream(message, model: nil, instructions: nil, previous_response_id: nil, max_output_tokens: nil, debug: false, options: {}, &block)
5
+ raise ArgumentError, "chat_stream requires a block" unless block
6
+
7
+ status = "unknown"
8
+ text = +""
9
+ response_id = nil
10
+ events = debug ? [] : nil
11
+ interaction = nil
12
+ raw = nil
13
+ callback_error = nil
14
+ begin
15
+ payload = chat_payload(message, model: model, instructions: instructions,
16
+ previous_response_id: previous_response_id,
17
+ max_output_tokens: max_output_tokens, options: options, stream: true)
18
+ uri = URI.parse("#{API_BASE_URL}/interactions")
19
+ request = Net::HTTP::Post.new(uri)
20
+ headers.each { |key, value| request[key] = value }
21
+ request["Accept"] = "text/event-stream"
22
+ request.body = JSON.generate(payload)
23
+ result = nil
24
+ step_types = {}
25
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
26
+ http.open_timeout = timeout
27
+ http.read_timeout = timeout
28
+ catch(:ai_lite_gemini_finished) do
29
+ http.request(request) do |response|
30
+ status = response.code.to_i
31
+ unless status.between?(200, 299)
32
+ raw = +""
33
+ response.read_body { |chunk| raw << chunk }
34
+ raw = JSON.parse(raw)
35
+ result = result_envelope(status: status, error: error_message(raw), raw: raw, debug: debug)
36
+ throw :ai_lite_gemini_finished
37
+ end
38
+ each_gemini_stream_event(response) do |event|
39
+ events << event if events
40
+ raw = { "interaction" => interaction, "events" => events } if debug
41
+ case event["event_type"]
42
+ when "interaction.created"
43
+ interaction = event.fetch("interaction")
44
+ response_id = interaction["id"] || response_id
45
+ when "step.start"
46
+ step_types[event.fetch("index")] = event.fetch("step").fetch("type")
47
+ when "step.delta"
48
+ delta = event.fetch("delta")
49
+ if step_types[event["index"]] == "model_output" && delta["type"] == "text"
50
+ chunk = delta.fetch("text")
51
+ text << chunk
52
+ begin
53
+ block.call(chunk)
54
+ rescue StandardError => e
55
+ callback_error = e
56
+ raise
57
+ end
58
+ end
59
+ when "interaction.status_update"
60
+ response_id = event["interaction_id"] || response_id
61
+ if %w[failed cancelled incomplete requires_action].include?(event["status"])
62
+ result = result_envelope(status: status, content: text.empty? ? nil : text,
63
+ response_id: response_id, error: "Gemini interaction #{event['status']}", raw: raw, debug: debug)
64
+ throw :ai_lite_gemini_finished
65
+ end
66
+ when "interaction.completed"
67
+ interaction = event.fetch("interaction")
68
+ response_id = interaction["id"] || response_id
69
+ raw = { "interaction" => interaction, "events" => events } if debug
70
+ error = if interaction["error"]
71
+ error_message(interaction)
72
+ elsif interaction["status"] != "completed"
73
+ "Gemini interaction #{interaction['status'] || 'has no status'}"
74
+ end
75
+ content = text.empty? ? nil : text
76
+ unless error || content.nil?
77
+ begin
78
+ content = JSON.parse(text.strip)
79
+ rescue JSON::ParserError
80
+ content = text.strip
81
+ end
82
+ end
83
+ result = result_envelope(status: status, content: content, response_id: response_id,
84
+ error: error, raw: raw, debug: debug)
85
+ throw :ai_lite_gemini_finished
86
+ when "error"
87
+ result = result_envelope(status: status, content: text.empty? ? nil : text,
88
+ response_id: response_id, error: error_message(event), raw: raw, debug: debug)
89
+ throw :ai_lite_gemini_finished
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ result || result_envelope(status: status, content: text.empty? ? nil : text, response_id: response_id,
96
+ error: "Stream ended before a terminal interaction event", raw: raw, debug: debug)
97
+ rescue StandardError => e
98
+ raise if callback_error.equal?(e)
99
+
100
+ result_envelope(status: status, content: text.empty? ? nil : text, response_id: response_id,
101
+ error: e.message, raw: raw, debug: debug)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def each_gemini_stream_event(response)
108
+ buffer = +"".b
109
+ data = []
110
+ consume = lambda do
111
+ while (boundary = buffer.index(/[\r\n]/))
112
+ # A trailing CR might be the first byte of a CRLF split across reads.
113
+ break if buffer.getbyte(boundary) == 13 && boundary == buffer.bytesize - 1
114
+
115
+ width = buffer.byteslice(boundary, 2) == "\r\n" ? 2 : 1
116
+ line = buffer.slice!(0, boundary + width).byteslice(0, boundary)
117
+ if line.empty?
118
+ unless data.empty?
119
+ payload = data.join("\n").force_encoding(Encoding::UTF_8)
120
+ data.clear
121
+ unless payload == "[DONE]"
122
+ event = JSON.parse(payload)
123
+ raise "Invalid streaming event: expected an object" unless event.is_a?(Hash)
124
+ yield event
125
+ end
126
+ end
127
+ elsif line.start_with?("data:")
128
+ data << line.sub(/\Adata: ?/, "")
129
+ end
130
+ end
131
+ end
132
+ response.read_body do |chunk|
133
+ buffer << chunk.b
134
+ consume.call
135
+ end
136
+ # A final lone CR is a complete line delimiter at EOF.
137
+ if buffer.end_with?("\r")
138
+ buffer << "\n"
139
+ consume.call
140
+ end
141
+ end
142
+
143
+ end
144
+
145
+ include Streaming
146
+ private_constant :Streaming
147
+ end
148
+ end
@@ -0,0 +1,36 @@
1
+ class AiLite
2
+ class Gemini
3
+ module YouTube
4
+ def youtube(url, prompt:, **kwargs)
5
+ unless prompt.is_a?(String) && !prompt.strip.empty?
6
+ raise ArgumentError, "prompt must be a nonempty string"
7
+ end
8
+ uri = URI.parse(url) if url.is_a?(String)
9
+ unless uri.is_a?(URI::HTTPS) && !uri.userinfo && uri.port == 443
10
+ raise ArgumentError, "Expected an HTTPS YouTube video URL"
11
+ end
12
+ video_id = case uri.host&.downcase
13
+ when "youtu.be"
14
+ uri.path.delete_prefix("/")
15
+ when "youtube.com", "www.youtube.com", "m.youtube.com"
16
+ if uri.path == "/watch"
17
+ ids = URI.decode_www_form(uri.query.to_s).select { |key, _| key == "v" }
18
+ ids.first[1] if ids.length == 1
19
+ else
20
+ uri.path.match(%r{\A/(?:shorts|embed|live)/([^/]+)\z})&.captures&.first
21
+ end
22
+ end
23
+ unless video_id && video_id.match?(/\A[A-Za-z0-9_-]{11}\z/)
24
+ raise ArgumentError, "Expected a YouTube video URL with a valid video ID"
25
+ end
26
+
27
+ chat([{ type: "video", uri: url }, { type: "text", text: prompt }], **kwargs)
28
+ rescue ArgumentError, URI::InvalidURIError => e
29
+ result_envelope(status: "unknown", error: e.message, debug: kwargs[:debug])
30
+ end
31
+ end
32
+
33
+ include YouTube
34
+ private_constant :YouTube
35
+ end
36
+ end
@@ -0,0 +1,109 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "base64"
5
+ require_relative "version"
6
+ require_relative "gemini/configuration"
7
+ require_relative "gemini/chat"
8
+ require_relative "gemini/embeddings"
9
+ require_relative "gemini/images"
10
+ require_relative "gemini/streaming"
11
+ require_relative "gemini/youtube"
12
+ require_relative "gemini/models"
13
+
14
+ class AiLite
15
+ class Gemini
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield configuration
23
+ reset_client!
24
+ configuration
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ reset_client!
30
+ configuration
31
+ end
32
+
33
+ def client
34
+ @client ||= new
35
+ end
36
+
37
+ def reset_client!
38
+ @client = nil
39
+ end
40
+
41
+ def youtube(url, prompt:, **kwargs)
42
+ client.youtube(url, prompt: prompt, **kwargs)
43
+ end
44
+
45
+ def chat_stream(message, **kwargs, &block)
46
+ client.chat_stream(message, **kwargs, &block)
47
+ end
48
+
49
+ def image(prompt, **kwargs)
50
+ client.image(prompt, **kwargs)
51
+ end
52
+
53
+ def embed(input, **kwargs)
54
+ client.embed(input, **kwargs)
55
+ end
56
+
57
+ def chat(message, **kwargs)
58
+ client.chat(message, **kwargs)
59
+ end
60
+ end
61
+
62
+ attr_reader :api_key, :model, :embedding_model, :image_model, :timeout, :max_output_tokens, :headers
63
+
64
+ def initialize(api_key: nil, model: nil, embedding_model: nil, image_model: nil, timeout: nil, max_output_tokens: nil)
65
+ config = self.class.configuration
66
+ @api_key = api_key || config.api_key || ENV["GEMINI_API_KEY"]
67
+ raise ArgumentError, "Missing Gemini API key" if @api_key.to_s.strip.empty?
68
+
69
+ @model = model || config.model
70
+ @embedding_model = embedding_model || config.embedding_model
71
+ @image_model = image_model || config.image_model
72
+ @timeout = timeout || config.timeout
73
+ @max_output_tokens = max_output_tokens || config.max_output_tokens
74
+ @headers = { "x-goog-api-key" => @api_key, "Content-Type" => "application/json" }
75
+ end
76
+
77
+ private
78
+
79
+ def post_interaction(payload)
80
+ post_json(payload, endpoint: "#{API_BASE_URL}/interactions")
81
+ end
82
+
83
+ def post_json(payload, endpoint:)
84
+ uri = URI.parse(endpoint)
85
+ request = Net::HTTP::Post.new(uri)
86
+ headers.each { |key, value| request[key] = value }
87
+ request.body = JSON.generate(payload)
88
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
89
+ http.open_timeout = timeout
90
+ http.read_timeout = timeout
91
+ http.request(request)
92
+ end
93
+ end
94
+
95
+ def result_envelope(status:, content: nil, error: nil, response_id: nil, raw: nil, debug: false)
96
+ {
97
+ "content" => content, "response_id" => response_id, "status" => status,
98
+ "error" => error, "raw" => debug ? raw : nil
99
+ }
100
+ end
101
+
102
+ def error_message(raw)
103
+ return raw.to_s unless raw.is_a?(Hash)
104
+
105
+ error = raw["error"]
106
+ error.is_a?(Hash) ? (error["message"] || error["status"] || error.to_s) : (error || raw["message"] || raw.to_s)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,51 @@
1
+ class AiLite
2
+ class OpenAI
3
+ API_BASE_URL = "https://api.openai.com/v1".freeze
4
+ DEFAULT_MODEL = "gpt-5.5".freeze
5
+ DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
6
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
7
+ DEFAULT_IMAGE_MODEL = "gpt-image-2".freeze
8
+ DEFAULT_SPEECH_MODEL = "gpt-4o-mini-tts".freeze
9
+ DEFAULT_SPEECH_VOICE = "alloy".freeze
10
+ DEFAULT_SPEECH_FORMAT = "mp3".freeze
11
+ DEFAULT_TRANSCRIPTION_MODEL = "gpt-transcribe".freeze
12
+ DEFAULT_TIMEOUT = 120
13
+ DEFAULT_MAX_OUTPUT_TOKENS = 2000
14
+ IMAGE_MIME_TYPES = {
15
+ ".gif" => "image/gif",
16
+ ".jpeg" => "image/jpeg",
17
+ ".jpg" => "image/jpeg",
18
+ ".png" => "image/png",
19
+ ".webp" => "image/webp"
20
+ }.freeze
21
+ AUDIO_MIME_TYPES = {
22
+ ".flac" => "audio/flac",
23
+ ".m4a" => "audio/mp4",
24
+ ".mp3" => "audio/mpeg",
25
+ ".mp4" => "audio/mp4",
26
+ ".mpeg" => "audio/mpeg",
27
+ ".mpga" => "audio/mpeg",
28
+ ".ogg" => "audio/ogg",
29
+ ".wav" => "audio/wav",
30
+ ".webm" => "audio/webm"
31
+ }.freeze
32
+
33
+ class Configuration
34
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :image_model, :speech_model, :speech_voice, :transcription_model, :timeout, :max_output_tokens
35
+
36
+ def initialize
37
+ @api_key = nil
38
+ @model = DEFAULT_MODEL
39
+ @moderation_model = DEFAULT_MODERATION_MODEL
40
+ @embedding_model = DEFAULT_EMBEDDING_MODEL
41
+ @image_model = DEFAULT_IMAGE_MODEL
42
+ @speech_model = DEFAULT_SPEECH_MODEL
43
+ @speech_voice = DEFAULT_SPEECH_VOICE
44
+ @transcription_model = DEFAULT_TRANSCRIPTION_MODEL
45
+ @timeout = DEFAULT_TIMEOUT
46
+ @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
47
+ end
48
+ end
49
+
50
+ end
51
+ end
@@ -0,0 +1,96 @@
1
+ class AiLite
2
+ class OpenAI
3
+ module Models
4
+ def self.configuration_snapshot(source)
5
+ {
6
+ "chat" => source.model,
7
+ "moderation" => source.moderation_model,
8
+ "embedding" => source.embedding_model,
9
+ "image" => source.image_model,
10
+ "speech" => source.speech_model,
11
+ "transcription" => source.transcription_model
12
+ }.transform_values { |value| value.is_a?(String) ? value.dup : value }
13
+ end
14
+
15
+ def configured_models
16
+ Models.configuration_snapshot(self)
17
+ end
18
+
19
+ def available_models(debug: false)
20
+ response = nil
21
+ raw = nil
22
+ uri = URI.parse("#{API_BASE_URL}/models")
23
+ request = Net::HTTP::Get.new(uri)
24
+ headers.each { |key, value| request[key] = value }
25
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
26
+ http.open_timeout = timeout
27
+ http.read_timeout = timeout
28
+ http.request(request)
29
+ end
30
+ raw = response.body
31
+ raw = JSON.parse(raw)
32
+ unless success_status?(response.code.to_i)
33
+ return prettify_data(status: response.code.to_i, error: error_message(raw), raw: raw, debug: debug)
34
+ end
35
+
36
+ unless raw.is_a?(Hash) && raw["data"].is_a?(Array) && raw["data"].all? { |item|
37
+ item.is_a?(Hash) && item["id"].is_a?(String) && !item["id"].empty?
38
+ }
39
+ raise "Invalid model list response"
40
+ end
41
+
42
+ prettify_data(status: response.code.to_i, content: raw["data"].map { |item| item["id"] }.uniq,
43
+ raw: raw, debug: debug)
44
+ rescue StandardError => e
45
+ prettify_data(status: response_status(response), error: e.message, raw: raw, debug: debug)
46
+ end
47
+
48
+ # nil means the lookup failed; false means a successful lookup did not find it.
49
+ def model_available?(model_id)
50
+ unless model_id.is_a?(String) && !model_id.strip.empty?
51
+ raise ArgumentError, "model_id must be a nonempty string"
52
+ end
53
+
54
+ result = available_models
55
+ return nil if result["error"]
56
+
57
+ result["content"].include?(model_id)
58
+ end
59
+
60
+ def check_configured_models(debug: false)
61
+ configured = configured_models
62
+ result = available_models(debug: debug)
63
+ report = configured.each_with_object({}) do |(usage, model), entries|
64
+ entries[usage] = {
65
+ "model" => model,
66
+ "usage" => usage,
67
+ "available" => result["error"] ? nil : result["content"].include?(model),
68
+ "error" => result["error"]
69
+ }
70
+ end
71
+ result.merge("content" => report)
72
+ end
73
+ end
74
+
75
+ include Models
76
+ private_constant :Models
77
+
78
+ class << self
79
+ def configured_models
80
+ Models.configuration_snapshot(configuration)
81
+ end
82
+
83
+ def available_models(**kwargs)
84
+ client.available_models(**kwargs)
85
+ end
86
+
87
+ def model_available?(model_id)
88
+ client.model_available?(model_id)
89
+ end
90
+
91
+ def check_configured_models(**kwargs)
92
+ client.check_configured_models(**kwargs)
93
+ end
94
+ end
95
+ end
96
+ end