ai-lite 0.6.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +368 -8
- data/lib/ai_lite/gemini/chat.rb +78 -0
- data/lib/ai_lite/gemini/configuration.rb +19 -0
- data/lib/ai_lite/gemini/models.rb +102 -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 +92 -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_models_test.rb +112 -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 +27 -6
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class GeminiModelsTest < Minitest::Test
|
|
4
|
+
Response = Struct.new(:code, :body)
|
|
5
|
+
class Http
|
|
6
|
+
attr_accessor :open_timeout, :read_timeout
|
|
7
|
+
attr_reader :requests
|
|
8
|
+
def initialize(pages)
|
|
9
|
+
@pages, @requests = pages, []
|
|
10
|
+
end
|
|
11
|
+
def request(request)
|
|
12
|
+
@requests << request
|
|
13
|
+
page = @pages.shift
|
|
14
|
+
raise page if page.is_a?(Exception)
|
|
15
|
+
raise "Unexpected request" unless page
|
|
16
|
+
Response.new(page[0], page[1].is_a?(String) ? page[1] : JSON.generate(page[1]))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
def setup
|
|
20
|
+
AiLite::Gemini.reset_configuration!
|
|
21
|
+
@client = AiLite::Gemini.new(api_key: "key", model: "models/test-model", timeout: 7)
|
|
22
|
+
end
|
|
23
|
+
def with_pages(*pages)
|
|
24
|
+
http = Http.new(pages)
|
|
25
|
+
original = Net::HTTP.method(:start)
|
|
26
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
27
|
+
raise "Wrong host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
28
|
+
block.call(http)
|
|
29
|
+
end
|
|
30
|
+
yield http
|
|
31
|
+
ensure
|
|
32
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
33
|
+
end
|
|
34
|
+
def page(names, token = nil)
|
|
35
|
+
result = { "models" => names.map { |id| { "name" => "models/#{id}", "supportedGenerationMethods" => ["generateContent"] } } }
|
|
36
|
+
result["nextPageToken"] = token if token
|
|
37
|
+
["200", result]
|
|
38
|
+
end
|
|
39
|
+
def test_pagination_normalization_metadata_and_http
|
|
40
|
+
with_pages(page(["first"], "a+b/&="), page(["test-model", "first"])) do |http|
|
|
41
|
+
result = @client.available_models(debug: true)
|
|
42
|
+
assert_equal ["first", "test-model"], result["content"]
|
|
43
|
+
assert_nil result["error"]
|
|
44
|
+
assert_equal 2, result.dig("raw", "pages").size
|
|
45
|
+
assert_equal ["generateContent"], result.dig("raw", "pages", 0, "models", 0, "supportedGenerationMethods")
|
|
46
|
+
assert_equal 2, http.requests.size
|
|
47
|
+
assert_equal "/v1beta/models", http.requests[0].path
|
|
48
|
+
request = http.requests[1]
|
|
49
|
+
assert_instance_of Net::HTTP::Get, request
|
|
50
|
+
assert_equal [["pageToken", "a+b/&="]], URI.decode_www_form(URI(request.path).query)
|
|
51
|
+
assert_equal "key", request["x-goog-api-key"]
|
|
52
|
+
assert_nil request["Authorization"]
|
|
53
|
+
assert_nil request.body
|
|
54
|
+
assert_equal 7, http.read_timeout
|
|
55
|
+
assert_equal 7, http.open_timeout
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
def test_predicate_matches_exact_ids_with_optional_resource_prefix
|
|
59
|
+
{ "test-model" => true, "models/test-model" => true, "test" => false, "TEST-MODEL" => false }.each do |name, expected|
|
|
60
|
+
with_pages(page(["test-model"])) { assert_equal expected, @client.model_available?(name) }
|
|
61
|
+
end
|
|
62
|
+
[nil, "", " ", :test].each { |name| assert_raises(ArgumentError) { @client.model_available?(name) } }
|
|
63
|
+
with_pages(["200", {}]) { assert_equal [], @client.available_models["content"] }
|
|
64
|
+
with_pages(page([])) { assert_equal false, @client.model_available?("missing") }
|
|
65
|
+
end
|
|
66
|
+
def test_local_configuration_and_single_paginated_check
|
|
67
|
+
with_pages do |http|
|
|
68
|
+
assert_equal({ "chat" => AiLite::Gemini::DEFAULT_MODEL }, AiLite::Gemini.configured_models)
|
|
69
|
+
snapshot = @client.configured_models
|
|
70
|
+
snapshot["chat"].replace("changed")
|
|
71
|
+
assert_equal "models/test-model", @client.model
|
|
72
|
+
assert_empty http.requests
|
|
73
|
+
end
|
|
74
|
+
with_pages(page(["other"], "next"), page(["test-model"])) do |http|
|
|
75
|
+
result = @client.check_configured_models
|
|
76
|
+
assert_equal 2, http.requests.size
|
|
77
|
+
assert_equal({ "chat" => { "model" => "models/test-model", "usage" => "chat", "available" => true, "error" => nil } }, result["content"])
|
|
78
|
+
assert_nil result["raw"]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
def test_failed_later_pages_do_not_return_partial_success
|
|
82
|
+
[["200", { "error" => { "message" => "Unexpected error" } }], ["403", { "error" => { "message" => "Denied" } }], ["200", "invalid"],
|
|
83
|
+
["200", { "models" => [{}] }], ["200", []], ["200", { "nextPageToken" => 3 }],
|
|
84
|
+
IOError.new("Disconnected")].each do |failure|
|
|
85
|
+
with_pages(page(["test-model"], "next"), failure) do
|
|
86
|
+
result = @client.available_models(debug: true)
|
|
87
|
+
assert_nil result["content"]
|
|
88
|
+
refute_nil result["error"]
|
|
89
|
+
refute_empty result.dig("raw", "pages")
|
|
90
|
+
end
|
|
91
|
+
with_pages(failure) { assert_nil @client.model_available?("test-model") }
|
|
92
|
+
with_pages(failure) do
|
|
93
|
+
result = @client.check_configured_models
|
|
94
|
+
assert_nil result.dig("content", "chat", "available")
|
|
95
|
+
refute_nil result["error"]
|
|
96
|
+
assert_equal result["error"], result.dig("content", "chat", "error")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
with_pages(page([], "same"), page([], "same")) do |http|
|
|
100
|
+
assert_match(/Repeated/, @client.available_models["error"])
|
|
101
|
+
assert_equal 2, http.requests.size
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
def test_class_delegation_and_provider_isolation
|
|
105
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.model = "test-model" }
|
|
106
|
+
original = AiLite::OpenAI.configured_models
|
|
107
|
+
with_pages(page(["test-model"])) { assert_equal ["test-model"], AiLite::Gemini.available_models["content"] }
|
|
108
|
+
with_pages(page(["test-model"])) { assert_equal true, AiLite::Gemini.model_available?("test-model") }
|
|
109
|
+
with_pages(page([])) { assert_equal false, AiLite::Gemini.check_configured_models.dig("content", "chat", "available") }
|
|
110
|
+
assert_equal original, AiLite::OpenAI.configured_models
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class GeminiStreamingTest < Minitest::Test
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :code, :read_count
|
|
6
|
+
def initialize(chunks, code)
|
|
7
|
+
@chunks, @code, @read_count = chunks, code, 0
|
|
8
|
+
end
|
|
9
|
+
def read_body
|
|
10
|
+
@chunks.each do |chunk|
|
|
11
|
+
raise chunk if chunk.is_a?(Exception)
|
|
12
|
+
@read_count += 1
|
|
13
|
+
yield chunk
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
class Http
|
|
18
|
+
attr_accessor :open_timeout, :read_timeout
|
|
19
|
+
attr_reader :request_received
|
|
20
|
+
def initialize(response)
|
|
21
|
+
@response = response
|
|
22
|
+
end
|
|
23
|
+
def request(request)
|
|
24
|
+
@request_received = request
|
|
25
|
+
yield @response
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
def setup
|
|
29
|
+
AiLite::Gemini.reset_configuration!
|
|
30
|
+
@client = AiLite::Gemini.new(api_key: "test-key", timeout: 8)
|
|
31
|
+
end
|
|
32
|
+
def event(type, fields = {})
|
|
33
|
+
"data: #{JSON.generate(fields.merge('event_type' => type))}\n\n"
|
|
34
|
+
end
|
|
35
|
+
def start
|
|
36
|
+
event("interaction.created", "interaction" => { "id" => "stream-id", "status" => "in_progress" }) +
|
|
37
|
+
event("step.start", "index" => 0, "step" => { "type" => "model_output" })
|
|
38
|
+
end
|
|
39
|
+
def delta(text, index = 0)
|
|
40
|
+
event("step.delta", "index" => index, "delta" => { "type" => "text", "text" => text })
|
|
41
|
+
end
|
|
42
|
+
def finish(status = "completed")
|
|
43
|
+
event("interaction.completed", "interaction" => { "id" => "stream-id", "status" => status, "usage" => { "total_tokens" => 4 } })
|
|
44
|
+
end
|
|
45
|
+
def with_http(chunks, code = "200")
|
|
46
|
+
response = Response.new(chunks, code)
|
|
47
|
+
http = Http.new(response)
|
|
48
|
+
original = Net::HTTP.method(:start)
|
|
49
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
50
|
+
raise "Wrong provider" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
51
|
+
block.call(http)
|
|
52
|
+
end
|
|
53
|
+
yield http, response
|
|
54
|
+
ensure
|
|
55
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
56
|
+
end
|
|
57
|
+
def test_incremental_delivery_request_options_and_partial_terminal_resource
|
|
58
|
+
options = { "stream" => false, generation_config: { temperature: 0.2 } }
|
|
59
|
+
original = Marshal.dump(options)
|
|
60
|
+
with_http([start, delta("Hi"), delta(" there"), finish]) do |http, response|
|
|
61
|
+
pieces = []
|
|
62
|
+
result = @client.chat_stream([{ type: "text", text: "Hello" }], model: "custom-model",
|
|
63
|
+
instructions: "Brief", previous_response_id: "previous", max_output_tokens: 50,
|
|
64
|
+
options: options, debug: true) do |text|
|
|
65
|
+
pieces << text
|
|
66
|
+
assert_equal pieces.size + 1, response.read_count
|
|
67
|
+
end
|
|
68
|
+
assert_equal ["Hi", " there"], pieces
|
|
69
|
+
assert_equal "Hi there", result["content"]
|
|
70
|
+
assert_equal "stream-id", result["response_id"]
|
|
71
|
+
assert_nil result["error"]
|
|
72
|
+
assert_equal 4, result.dig("raw", "interaction", "usage", "total_tokens")
|
|
73
|
+
assert_equal 5, result["raw"]["events"].size
|
|
74
|
+
request = http.request_received
|
|
75
|
+
assert_equal "/v1beta/interactions", request.path
|
|
76
|
+
assert_equal "test-key", request["x-goog-api-key"]
|
|
77
|
+
assert_nil request["Authorization"]
|
|
78
|
+
assert_equal "text/event-stream", request["Accept"]
|
|
79
|
+
payload = JSON.parse(request.body)
|
|
80
|
+
assert_equal true, payload["stream"]
|
|
81
|
+
assert_equal "previous", payload["previous_interaction_id"]
|
|
82
|
+
assert_equal "Brief", payload["system_instruction"]
|
|
83
|
+
assert_equal "custom-model", payload["model"]
|
|
84
|
+
assert_equal 50, payload.dig("generation_config", "max_output_tokens")
|
|
85
|
+
assert_equal 0.2, payload.dig("generation_config", "temperature")
|
|
86
|
+
assert_equal 8, http.read_timeout
|
|
87
|
+
assert_equal 8, http.open_timeout
|
|
88
|
+
assert_equal original, Marshal.dump(options)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
def test_fragmented_utf8_multiline_sse_and_reasoning_filter
|
|
92
|
+
stream = ": heartbeat\r\n\r\n" + start +
|
|
93
|
+
event("step.start", "index" => 1, "step" => { "type" => "thought" }) + delta("Secret", 1) +
|
|
94
|
+
"data: {\"event_type\":\"step.delta\",\r\ndata: \"index\":0,\"delta\":{\"type\":\"text\",\"text\":\"🌍\"}}\r\n\r\n" +
|
|
95
|
+
event("step.stop", "index" => 0) + finish
|
|
96
|
+
with_http(stream.bytes.map(&:chr)) do
|
|
97
|
+
parts = []
|
|
98
|
+
result = @client.chat_stream("Hello") { |text| parts << text }
|
|
99
|
+
assert_equal ["🌍"], parts
|
|
100
|
+
assert_equal "🌍", result["content"]
|
|
101
|
+
assert_nil result["raw"]
|
|
102
|
+
assert_nil result["error"]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
def test_json_and_class_delegation
|
|
106
|
+
AiLite::Gemini.configure { |c| c.api_key = "key" }
|
|
107
|
+
with_http([start + delta('{"ok":true}') + finish]) do
|
|
108
|
+
result = AiLite::Gemini.chat_stream("JSON") { |_| }
|
|
109
|
+
assert_equal({ "ok" => true }, result["content"])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
def test_failure_events_and_interrupted_streams
|
|
113
|
+
tails = [finish("incomplete"), finish("failed"), finish("requires_action"),
|
|
114
|
+
event("interaction.status_update", "status" => "cancelled"),
|
|
115
|
+
event("error", "error" => { "message" => "Overloaded" }),
|
|
116
|
+
"data: [DONE]\n\n", "data: {broken}\n\n", "data: {", IOError.new("Disconnected")]
|
|
117
|
+
tails.each do |tail|
|
|
118
|
+
with_http([start, delta("Partial"), tail]) do
|
|
119
|
+
result = @client.chat_stream("Hi", debug: true) { |_| }
|
|
120
|
+
assert_equal "Partial", result["content"]
|
|
121
|
+
assert_equal "stream-id", result["response_id"]
|
|
122
|
+
refute_nil result["error"]
|
|
123
|
+
refute_nil result["raw"]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
with_http([start]) do
|
|
127
|
+
assert_match(/terminal/, @client.chat_stream("Hi") { |_| }["error"])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
def test_http_errors_and_block_exceptions
|
|
131
|
+
with_http(['{"error":{"message":"Denied"}}'], "403") do
|
|
132
|
+
result = @client.chat_stream("Hi") { flunk "Unexpected callback" }
|
|
133
|
+
assert_equal 403, result["status"]
|
|
134
|
+
assert_equal "Denied", result["error"]
|
|
135
|
+
end
|
|
136
|
+
with_http(["Bad gateway"], "502") do
|
|
137
|
+
result = @client.chat_stream("Hi", debug: true) { flunk "Unexpected callback" }
|
|
138
|
+
assert_equal 502, result["status"]
|
|
139
|
+
assert_equal "Bad gateway", result["raw"]
|
|
140
|
+
refute_nil result["error"]
|
|
141
|
+
end
|
|
142
|
+
assert_raises(ArgumentError) { @client.chat_stream("Hi") }
|
|
143
|
+
with_http([start, delta("Hello")]) do
|
|
144
|
+
failure = RuntimeError.new("UI error")
|
|
145
|
+
assert_same failure, assert_raises(RuntimeError) { @client.chat_stream("Hi") { raise failure } }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
def test_mixed_sse_newlines_and_lone_cr_at_eof
|
|
149
|
+
stream = start.gsub("\n\n", "\r\n\n") + delta("Hello") + finish.gsub("\n", "\r")
|
|
150
|
+
with_http(stream.bytes.map(&:chr)) do
|
|
151
|
+
parts = []
|
|
152
|
+
result = @client.chat_stream("Hi") { |text| parts << text }
|
|
153
|
+
assert_equal ["Hello"], parts
|
|
154
|
+
assert_equal "Hello", result["content"]
|
|
155
|
+
assert_nil result["error"]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_invalid_request_does_not_use_http
|
|
160
|
+
with_http([]) do |http, _|
|
|
161
|
+
refute_nil @client.chat_stream("Hi", options: { background: true }) { |_| }["error"]
|
|
162
|
+
refute_nil @client.chat_stream(nil) { |_| }["error"]
|
|
163
|
+
assert_nil http.request_received
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/test/gemini_test.rb
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
class GeminiTest < Minitest::Test
|
|
6
|
+
Response = Struct.new(:code, :body)
|
|
7
|
+
class Http
|
|
8
|
+
attr_accessor :open_timeout, :read_timeout
|
|
9
|
+
attr_reader :request_received
|
|
10
|
+
def initialize(response)
|
|
11
|
+
@response = response
|
|
12
|
+
end
|
|
13
|
+
def request(request)
|
|
14
|
+
@request_received = request
|
|
15
|
+
raise @response if @response.is_a?(Exception)
|
|
16
|
+
@response
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup
|
|
21
|
+
AiLite::Gemini.reset_configuration!
|
|
22
|
+
AiLite::OpenAI.reset_configuration!
|
|
23
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def body(text = "Hello")
|
|
27
|
+
{ "id" => "interaction_123", "status" => "completed", "steps" => [
|
|
28
|
+
{ "type" => "thought", "content" => [{ "type" => "text", "text" => "Hidden" }] },
|
|
29
|
+
{ "type" => "model_output", "content" => [{ "type" => "text", "text" => text }] }
|
|
30
|
+
], "usage" => { "total_tokens" => 10 } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_http(raw = body, status = "200")
|
|
34
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
35
|
+
http = Http.new(response)
|
|
36
|
+
original = Net::HTTP.method(:start)
|
|
37
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
38
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
39
|
+
block.call(http)
|
|
40
|
+
end
|
|
41
|
+
yield http
|
|
42
|
+
ensure
|
|
43
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_string_chat_transport_and_envelope
|
|
47
|
+
with_http do |http|
|
|
48
|
+
result = @client.chat("Tell me a joke")
|
|
49
|
+
assert_equal({ "content" => "Hello", "response_id" => "interaction_123", "status" => 200,
|
|
50
|
+
"error" => nil, "raw" => nil }, result)
|
|
51
|
+
request = http.request_received
|
|
52
|
+
assert_equal "/v1beta/interactions", request.path
|
|
53
|
+
assert_equal "gemini-key", request["x-goog-api-key"]
|
|
54
|
+
assert_nil request["Authorization"]
|
|
55
|
+
assert_nil URI(request.path).query
|
|
56
|
+
payload = JSON.parse(request.body)
|
|
57
|
+
assert_equal "Tell me a joke", payload["input"]
|
|
58
|
+
assert_equal AiLite::Gemini::DEFAULT_MODEL, payload["model"]
|
|
59
|
+
assert_equal 2000, payload.dig("generation_config", "max_output_tokens")
|
|
60
|
+
assert_equal 9, http.open_timeout
|
|
61
|
+
assert_equal 9, http.read_timeout
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_structured_inputs_and_history_pass_through_without_mutation
|
|
66
|
+
inputs = [
|
|
67
|
+
{ type: "video", uri: "https://www.youtube.com/watch?v=example" },
|
|
68
|
+
[{ type: "video", uri: "https://www.youtube.com/watch?v=example" }, { type: "text", text: "Summarize" }],
|
|
69
|
+
[{ type: "user_input", content: [{ type: "text", text: "Hello" }] },
|
|
70
|
+
{ type: "model_output", content: [{ type: "text", text: "Hi" }] }]
|
|
71
|
+
]
|
|
72
|
+
inputs.each do |input|
|
|
73
|
+
original = Marshal.dump(input)
|
|
74
|
+
with_http do |http|
|
|
75
|
+
assert_nil @client.chat(input)["error"]
|
|
76
|
+
assert_equal JSON.parse(JSON.generate(input)), JSON.parse(http.request_received.body)["input"]
|
|
77
|
+
assert_equal original, Marshal.dump(input)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_keywords_and_provider_options
|
|
83
|
+
options = { "generation_config" => { "temperature" => 0.4, "max_output_tokens" => 50 },
|
|
84
|
+
"previous_interaction_id" => "old", store: false, response_format: { type: "object" } }
|
|
85
|
+
original = Marshal.dump(options)
|
|
86
|
+
with_http(body('{"ok":true}')) do |http|
|
|
87
|
+
result = @client.chat("Hello", model: "custom-model", instructions: "Be brief",
|
|
88
|
+
previous_response_id: "previous", max_output_tokens: 25, debug: true, options: options)
|
|
89
|
+
payload = JSON.parse(http.request_received.body)
|
|
90
|
+
assert_equal "custom-model", payload["model"]
|
|
91
|
+
assert_equal "Be brief", payload["system_instruction"]
|
|
92
|
+
assert_equal "previous", payload["previous_interaction_id"]
|
|
93
|
+
refute payload.key?("previous_response_id")
|
|
94
|
+
assert_equal({ "temperature" => 0.4, "max_output_tokens" => 25 }, payload["generation_config"])
|
|
95
|
+
assert_equal false, payload["store"]
|
|
96
|
+
assert_equal({ "ok" => true }, result["content"])
|
|
97
|
+
assert_equal 10, result.dig("raw", "usage", "total_tokens")
|
|
98
|
+
assert_equal original, Marshal.dump(options)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_options_continuation_and_token_limit_without_keywords
|
|
103
|
+
with_http do |http|
|
|
104
|
+
@client.chat("Hi", options: { previous_interaction_id: "before", generation_config: { max_output_tokens: 33 } })
|
|
105
|
+
payload = JSON.parse(http.request_received.body)
|
|
106
|
+
assert_equal "before", payload["previous_interaction_id"]
|
|
107
|
+
assert_equal 33, payload.dig("generation_config", "max_output_tokens")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_config_and_cached_clients_are_isolated
|
|
112
|
+
AiLite::OpenAI.configure { |config| config.api_key = "openai-key"; config.model = "openai-model" }
|
|
113
|
+
openai = AiLite::OpenAI.client
|
|
114
|
+
AiLite::Gemini.configure do |config|
|
|
115
|
+
config.api_key = "configured-gemini"
|
|
116
|
+
config.model = "configured-model"
|
|
117
|
+
config.timeout = 14
|
|
118
|
+
config.max_output_tokens = 88
|
|
119
|
+
end
|
|
120
|
+
gemini = AiLite::Gemini.client
|
|
121
|
+
assert_same gemini, AiLite::Gemini.client
|
|
122
|
+
assert_equal "configured-gemini", gemini.api_key
|
|
123
|
+
assert_equal "configured-model", gemini.model
|
|
124
|
+
assert_equal 14, gemini.timeout
|
|
125
|
+
assert_equal 88, gemini.max_output_tokens
|
|
126
|
+
overridden = AiLite::Gemini.new(api_key: "explicit", model: "override", timeout: 3, max_output_tokens: 4)
|
|
127
|
+
assert_equal ["explicit", "override", 3, 4], [overridden.api_key, overridden.model, overridden.timeout, overridden.max_output_tokens]
|
|
128
|
+
AiLite::Gemini.configure { |config| config.api_key = "new-key" }
|
|
129
|
+
refute_same gemini, AiLite::Gemini.client
|
|
130
|
+
assert_same openai, AiLite::OpenAI.client
|
|
131
|
+
assert_equal "openai-key", openai.api_key
|
|
132
|
+
refute AiLite::Gemini < AiLite::OpenAI
|
|
133
|
+
%i[moderate transcribe image speak embed].each { |method| refute_respond_to gemini, method }
|
|
134
|
+
with_http do
|
|
135
|
+
assert_equal "Hello", AiLite::Gemini.chat("Hello")["content"]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_gemini_key_fallback_does_not_use_openai_key
|
|
140
|
+
previous = ENV.to_h.slice("GEMINI_API_KEY", "OPENAI_API_KEY", "OPEN_AI_TOKEN")
|
|
141
|
+
ENV.delete("GEMINI_API_KEY")
|
|
142
|
+
ENV["OPENAI_API_KEY"] = "openai-only"
|
|
143
|
+
ENV["OPEN_AI_TOKEN"] = "legacy-only"
|
|
144
|
+
assert_raises(ArgumentError) { AiLite::Gemini.new }
|
|
145
|
+
ENV["GEMINI_API_KEY"] = "env-key"
|
|
146
|
+
assert_equal "env-key", AiLite::Gemini.new.api_key
|
|
147
|
+
AiLite::Gemini.configure { |config| config.api_key = "config-key" }
|
|
148
|
+
assert_equal "config-key", AiLite::Gemini.new.api_key
|
|
149
|
+
assert_equal "explicit-key", AiLite::Gemini.new(api_key: "explicit-key").api_key
|
|
150
|
+
ensure
|
|
151
|
+
%w[GEMINI_API_KEY OPENAI_API_KEY OPEN_AI_TOKEN].each do |key|
|
|
152
|
+
previous.key?(key) ? ENV[key] = previous[key] : ENV.delete(key)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def test_errors_and_noncompleted_interactions
|
|
157
|
+
with_http({ "error" => { "code" => 403, "message" => "Permission denied" } }, "403") do
|
|
158
|
+
result = @client.chat("Hi", debug: true)
|
|
159
|
+
assert_equal 403, result["status"]
|
|
160
|
+
assert_equal "Permission denied", result["error"]
|
|
161
|
+
refute_nil result["raw"]
|
|
162
|
+
end
|
|
163
|
+
["bad json", [], {}, IOError.new("Disconnected")].each do |raw|
|
|
164
|
+
with_http(raw) { refute_nil @client.chat("Hi")["error"] }
|
|
165
|
+
end
|
|
166
|
+
%w[failed incomplete cancelled requires_action queued in_progress].each do |state|
|
|
167
|
+
with_http(body.merge("status" => state)) do
|
|
168
|
+
result = @client.chat("Hi", debug: true)
|
|
169
|
+
assert_includes result["error"], state
|
|
170
|
+
assert_equal "interaction_123", result["response_id"]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
with_http(body.merge("status" => "failed", "error" => { "message" => "Generation failed" })) do
|
|
174
|
+
assert_equal "Generation failed", @client.chat("Hi")["error"]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def test_unsupported_execution_modes_and_invalid_inputs_do_not_call_http
|
|
179
|
+
with_http do |http|
|
|
180
|
+
[{ stream: true }, { "background" => true }, { generation_config: nil }].each do |options|
|
|
181
|
+
refute_nil @client.chat("Hi", options: options)["error"]
|
|
182
|
+
end
|
|
183
|
+
refute_nil @client.chat(nil)["error"]
|
|
184
|
+
assert_nil http.request_received
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def test_direct_require
|
|
189
|
+
stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I#{File.expand_path('../lib', __dir__)}", "-e", <<~'CODE')
|
|
190
|
+
require "ai_lite/gemini"
|
|
191
|
+
abort unless AiLite::Gemini.new(api_key: "key").respond_to?(:chat)
|
|
192
|
+
abort if AiLite.const_defined?(:OpenAI, false)
|
|
193
|
+
require "ai_lite"
|
|
194
|
+
abort unless AiLite::OpenAI.new(api_key: "key").respond_to?(:chat)
|
|
195
|
+
CODE
|
|
196
|
+
assert status.success?, stderr
|
|
197
|
+
assert_empty stdout
|
|
198
|
+
assert_empty stderr
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "gemini_test"
|
|
2
|
+
|
|
3
|
+
class GeminiYouTubeTest < GeminiTest
|
|
4
|
+
def test_youtube_builds_video_input_and_forwards_chat_options
|
|
5
|
+
url = "https://www.youtube.com/watch?v=DV69qh0BzoA"
|
|
6
|
+
with_http do |http|
|
|
7
|
+
result = @client.youtube(url, prompt: "Summarize this video", model: "custom",
|
|
8
|
+
instructions: "Be brief", previous_response_id: "previous", max_output_tokens: 100,
|
|
9
|
+
debug: true, options: { store: false })
|
|
10
|
+
payload = JSON.parse(http.request_received.body)
|
|
11
|
+
assert_equal [{ "type" => "video", "uri" => url }, { "type" => "text", "text" => "Summarize this video" }], payload["input"]
|
|
12
|
+
assert_equal "custom", payload["model"]
|
|
13
|
+
assert_equal "Be brief", payload["system_instruction"]
|
|
14
|
+
assert_equal "previous", payload["previous_interaction_id"]
|
|
15
|
+
assert_equal 100, payload.dig("generation_config", "max_output_tokens")
|
|
16
|
+
assert_equal false, payload["store"]
|
|
17
|
+
assert_equal "Hello", result["content"]
|
|
18
|
+
refute_nil result["raw"]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_supported_url_forms_and_class_method
|
|
23
|
+
AiLite::Gemini.configure { |c| c.api_key = "test-key" }
|
|
24
|
+
%w[https://youtu.be/DV69qh0BzoA?t=10 https://m.youtube.com/watch?v=DV69qh0BzoA https://youtube.com/shorts/DV69qh0BzoA https://www.youtube.com/embed/DV69qh0BzoA https://youtube.com/live/DV69qh0BzoA].each do |url|
|
|
25
|
+
with_http do
|
|
26
|
+
assert_nil AiLite::Gemini.youtube(url, prompt: "Summarize")["error"]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_rejects_invalid_urls_and_prompts_before_network
|
|
32
|
+
[nil, "garbage", "http://youtube.com/watch?v=DV69qh0BzoA", "https://youtube.com.evil.test/watch?v=DV69qh0BzoA",
|
|
33
|
+
"https://user@youtube.com/watch?v=DV69qh0BzoA", "https://youtube.com/watch?v=short",
|
|
34
|
+
"https://youtube.com/playlist?list=DV69qh0BzoA", "https://youtu.be/DV69qh0BzoA/extra",
|
|
35
|
+
"https://youtube.com/watch?v=DV69qh0BzoA&v=DV69qh0BzoA"].each do |url|
|
|
36
|
+
with_http do |http|
|
|
37
|
+
refute_nil @client.youtube(url, prompt: "Summarize")["error"]
|
|
38
|
+
assert_nil http.request_received
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
[nil, "", " "].each do |prompt|
|
|
42
|
+
with_http do |http|
|
|
43
|
+
refute_nil @client.youtube("https://youtu.be/DV69qh0BzoA", prompt: prompt)["error"]
|
|
44
|
+
assert_nil http.request_received
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_provider_errors_are_preserved
|
|
50
|
+
with_http({ "error" => { "message" => "Video unavailable" } }, "400") do
|
|
51
|
+
result = @client.youtube("https://youtu.be/DV69qh0BzoA", prompt: "Summarize", debug: true)
|
|
52
|
+
assert_equal 400, result["status"]
|
|
53
|
+
assert_equal "Video unavailable", result["error"]
|
|
54
|
+
refute_nil result["raw"]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class OpenAIModelsTest < Minitest::Test
|
|
4
|
+
Response = Struct.new(:code, :body)
|
|
5
|
+
class Http
|
|
6
|
+
attr_accessor :open_timeout, :read_timeout
|
|
7
|
+
attr_reader :requests
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@response, @requests = response, []
|
|
10
|
+
end
|
|
11
|
+
def request(request)
|
|
12
|
+
@requests << request
|
|
13
|
+
raise @response if @response.is_a?(Exception)
|
|
14
|
+
@response
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
AiLite::OpenAI.reset_configuration!
|
|
20
|
+
@client = AiLite::OpenAI.new(api_key: "test-key", model: "chat-test", timeout: 7)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with_http(body, status = "200")
|
|
24
|
+
response = body.is_a?(Exception) ? body : Response.new(status, body.is_a?(String) ? body : JSON.generate(body))
|
|
25
|
+
http = Http.new(response)
|
|
26
|
+
original = Net::HTTP.method(:start)
|
|
27
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
28
|
+
raise "Unexpected host" unless host == "api.openai.com" && port == 443 && use_ssl
|
|
29
|
+
block.call(http)
|
|
30
|
+
end
|
|
31
|
+
yield http
|
|
32
|
+
ensure
|
|
33
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_get_model_ids_and_debug_metadata
|
|
37
|
+
body = { "data" => [{ "id" => "chat-test", "owned_by" => "openai" }, { "id" => "other" }] }
|
|
38
|
+
with_http(body) do |http|
|
|
39
|
+
result = @client.available_models(debug: true)
|
|
40
|
+
assert_equal ["chat-test", "other"], result["content"]
|
|
41
|
+
assert_equal body, result["raw"]
|
|
42
|
+
assert_equal 200, result["status"]
|
|
43
|
+
assert_nil result["error"]
|
|
44
|
+
assert_nil result["response_id"]
|
|
45
|
+
request = http.requests.fetch(0)
|
|
46
|
+
assert_instance_of Net::HTTP::Get, request
|
|
47
|
+
assert_equal "/v1/models", request.path
|
|
48
|
+
assert_equal "Bearer test-key", request["Authorization"]
|
|
49
|
+
assert_nil request.body
|
|
50
|
+
assert_equal 7, http.open_timeout
|
|
51
|
+
assert_equal 7, http.read_timeout
|
|
52
|
+
assert_nil @client.available_models["raw"]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_exact_predicate_and_empty_list
|
|
57
|
+
with_http("data" => [{ "id" => "chat-test" }]) do
|
|
58
|
+
assert_equal true, @client.model_available?("chat-test")
|
|
59
|
+
assert_equal false, @client.model_available?("chat")
|
|
60
|
+
assert_equal false, @client.model_available?("CHAT-TEST")
|
|
61
|
+
end
|
|
62
|
+
with_http("data" => []) do
|
|
63
|
+
assert_equal [], @client.available_models["content"]
|
|
64
|
+
assert_equal false, @client.model_available?("chat-test")
|
|
65
|
+
end
|
|
66
|
+
[nil, "", " ", :model].each do |input|
|
|
67
|
+
assert_raises(ArgumentError) { @client.model_available?(input) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_local_configuration_never_uses_network_or_needs_key
|
|
72
|
+
with_http(RuntimeError.new("Must not call network")) do |http|
|
|
73
|
+
AiLite::OpenAI.configure { |config| config.model = "global-model" }
|
|
74
|
+
assert_equal "global-model", AiLite::OpenAI.configured_models["chat"]
|
|
75
|
+
snapshot = @client.configured_models
|
|
76
|
+
assert_equal "chat-test", snapshot["chat"]
|
|
77
|
+
assert_equal %w[chat moderation embedding image speech transcription], snapshot.keys
|
|
78
|
+
snapshot["chat"].replace("changed")
|
|
79
|
+
assert_equal "chat-test", @client.model
|
|
80
|
+
assert_empty http.requests
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_check_uses_one_get_for_all_usages_including_duplicate_models
|
|
85
|
+
client = AiLite::OpenAI.new(api_key: "test-key", model: "same", embedding_model: "same")
|
|
86
|
+
with_http("data" => [{ "id" => "same" }]) do |http|
|
|
87
|
+
result = client.check_configured_models(debug: true)
|
|
88
|
+
assert_equal 1, http.requests.length
|
|
89
|
+
assert_equal 6, result["content"].length
|
|
90
|
+
assert_equal true, result.dig("content", "chat", "available")
|
|
91
|
+
assert_equal true, result.dig("content", "embedding", "available")
|
|
92
|
+
assert_equal false, result.dig("content", "image", "available")
|
|
93
|
+
assert_equal "chat", result.dig("content", "chat", "usage")
|
|
94
|
+
assert_nil result["error"]
|
|
95
|
+
refute_nil result["raw"]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_http_parse_shape_and_network_errors_are_not_missing_models
|
|
100
|
+
cases = [
|
|
101
|
+
[{ "error" => { "message" => "Unauthorized" } }, "401"],
|
|
102
|
+
[{ "error" => { "message" => "Rate limited" } }, "429"],
|
|
103
|
+
["Bad gateway", "502"], ["not json", "200"], [[], "200"],
|
|
104
|
+
[{ "data" => [{}] }, "200"], [{}, "200"],
|
|
105
|
+
[IOError.new("Connection lost"), "200"]
|
|
106
|
+
]
|
|
107
|
+
cases.each do |body, status|
|
|
108
|
+
with_http(body, status) do |http|
|
|
109
|
+
result = @client.available_models(debug: true)
|
|
110
|
+
refute_nil result["error"]
|
|
111
|
+
assert_nil result["content"]
|
|
112
|
+
assert_nil @client.model_available?("chat-test")
|
|
113
|
+
before = http.requests.length
|
|
114
|
+
report = @client.check_configured_models
|
|
115
|
+
assert_equal before + 1, http.requests.length
|
|
116
|
+
refute_nil report["error"]
|
|
117
|
+
report["content"].each_value do |entry|
|
|
118
|
+
assert_nil entry["available"]
|
|
119
|
+
assert_equal report["error"], entry["error"]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_class_and_legacy_delegation
|
|
126
|
+
AiLite::OpenAI.configure { |config| config.api_key = "test-key"; config.model = "chat-test" }
|
|
127
|
+
[AiLite::OpenAI, AiLite].each do |provider|
|
|
128
|
+
with_http("data" => [{ "id" => "chat-test" }]) do
|
|
129
|
+
capture_io do
|
|
130
|
+
assert_equal ["chat-test"], provider.available_models["content"]
|
|
131
|
+
assert_equal true, provider.model_available?("chat-test")
|
|
132
|
+
assert_equal "chat-test", provider.configured_models["chat"]
|
|
133
|
+
assert_equal true, provider.check_configured_models.dig("content", "chat", "available")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|