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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +456 -19
- data/lib/ai_lite/gemini/chat.rb +78 -0
- data/lib/ai_lite/gemini/configuration.rb +23 -0
- data/lib/ai_lite/gemini/embeddings.rb +58 -0
- data/lib/ai_lite/gemini/images.rb +56 -0
- data/lib/ai_lite/gemini/models.rb +104 -0
- data/lib/ai_lite/gemini/streaming.rb +148 -0
- data/lib/ai_lite/gemini/youtube.rb +36 -0
- data/lib/ai_lite/gemini.rb +109 -0
- data/lib/ai_lite/openai/configuration.rb +51 -0
- data/lib/ai_lite/openai/models.rb +96 -0
- data/lib/ai_lite/openai/streaming.rb +130 -0
- data/lib/ai_lite/openai.rb +607 -0
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +32 -624
- data/test/ai_lite_test.rb +196 -72
- data/test/gemini_embeddings_test.rb +122 -0
- data/test/gemini_images_test.rb +154 -0
- data/test/gemini_models_test.rb +113 -0
- data/test/gemini_streaming_test.rb +166 -0
- data/test/gemini_test.rb +200 -0
- data/test/gemini_youtube_test.rb +57 -0
- data/test/openai_models_test.rb +138 -0
- data/test/openai_streaming_test.rb +189 -0
- data/test/run.rb +3 -0
- metadata +32 -6
|
@@ -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
|