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,122 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class GeminiEmbeddingsTest < Minitest::Test
|
|
4
|
+
Response = Struct.new(:code, :body)
|
|
5
|
+
class Http
|
|
6
|
+
attr_accessor :open_timeout, :read_timeout
|
|
7
|
+
attr_reader :request_received
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@response = response
|
|
10
|
+
end
|
|
11
|
+
def request(request)
|
|
12
|
+
@request_received = request
|
|
13
|
+
raise @response if @response.is_a?(Exception)
|
|
14
|
+
@response
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
AiLite::Gemini.reset_configuration!
|
|
20
|
+
AiLite::OpenAI.reset_configuration!
|
|
21
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_http(raw = {}, status = "200")
|
|
25
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
26
|
+
http = Http.new(response)
|
|
27
|
+
original = Net::HTTP.method(:start)
|
|
28
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
29
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
30
|
+
block.call(http)
|
|
31
|
+
end
|
|
32
|
+
yield http
|
|
33
|
+
ensure
|
|
34
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_single_embedding_transport_and_envelope
|
|
38
|
+
with_http({ "embedding" => { "values" => [0.1, -0.2] } }) do |http|
|
|
39
|
+
result = @client.embed("Hello")
|
|
40
|
+
assert_equal({ "content" => [0.1, -0.2], "status" => 200, "error" => nil,
|
|
41
|
+
"raw" => nil, "response_id" => nil }, result)
|
|
42
|
+
assert_equal "/v1beta/models/gemini-embedding-001:embedContent", http.request_received.path
|
|
43
|
+
assert_equal "gemini-key", http.request_received["x-goog-api-key"]
|
|
44
|
+
assert_equal "application/json", http.request_received["Content-Type"]
|
|
45
|
+
assert_equal 9, http.read_timeout
|
|
46
|
+
assert_equal 9, http.open_timeout
|
|
47
|
+
assert_equal({ "model" => "models/gemini-embedding-001", "content" => { "parts" => [{ "text" => "Hello" }] } }, JSON.parse(http.request_received.body))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_batch_order_options_dimensions_and_debug
|
|
52
|
+
options = { taskType: "RETRIEVAL_DOCUMENT", title: "Guide", "outputDimensionality" => 8 }
|
|
53
|
+
original = Marshal.dump(options)
|
|
54
|
+
raw = { "embeddings" => [{ "values" => [1, 2] }, { "values" => [3, 4] }], "usageMetadata" => { "totalTokenCount" => 4 } }
|
|
55
|
+
with_http(raw) do |http|
|
|
56
|
+
result = @client.embed(["First", "Second"], model: "models/custom", dimensions: 2, encoding_format: "float", options: options, debug: true)
|
|
57
|
+
assert_equal [[1, 2], [3, 4]], result["content"]
|
|
58
|
+
assert_equal raw, result["raw"]
|
|
59
|
+
assert_equal "/v1beta/models/custom:batchEmbedContents", http.request_received.path
|
|
60
|
+
requests = JSON.parse(http.request_received.body)["requests"]
|
|
61
|
+
assert_equal ["First", "Second"], requests.map { |r| r.dig("content", "parts", 0, "text") }
|
|
62
|
+
requests.each do |request|
|
|
63
|
+
assert_equal "models/custom", request["model"]
|
|
64
|
+
assert_equal 2, request["outputDimensionality"]
|
|
65
|
+
assert_equal "RETRIEVAL_DOCUMENT", request["taskType"]
|
|
66
|
+
assert_equal "Guide", request["title"]
|
|
67
|
+
refute request.key?("encoding_format")
|
|
68
|
+
end
|
|
69
|
+
assert_equal original, Marshal.dump(options)
|
|
70
|
+
end
|
|
71
|
+
with_http({ "embeddings" => [{ "values" => [1] }] }) do
|
|
72
|
+
assert_equal [[1]], @client.embed(["Only one"])["content"]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_configuration_delegation_and_model_checks
|
|
77
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.embedding_model = "configured" }
|
|
78
|
+
assert_equal "configured", AiLite::Gemini.client.embedding_model
|
|
79
|
+
assert_equal "gemini-embedding-001", @client.embedding_model
|
|
80
|
+
assert_equal "override", AiLite::Gemini.new(embedding_model: "override").embedding_model
|
|
81
|
+
snapshot = AiLite::Gemini.configured_models
|
|
82
|
+
snapshot["embedding"].replace("changed")
|
|
83
|
+
assert_equal "configured", AiLite::Gemini.configuration.embedding_model
|
|
84
|
+
with_http({ "embedding" => { "values" => [1] } }) do |http|
|
|
85
|
+
assert_equal [1], AiLite::Gemini.embed("Hello")["content"]
|
|
86
|
+
assert_equal "/v1beta/models/configured:embedContent", http.request_received.path
|
|
87
|
+
end
|
|
88
|
+
with_http({ "models" => [{ "name" => "models/configured" }] }) do
|
|
89
|
+
assert_equal true, AiLite::Gemini.check_configured_models.dig("content", "embedding", "available")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_invalid_inputs_do_not_make_requests
|
|
94
|
+
with_http do |http|
|
|
95
|
+
[nil, [], "", ["ok", nil], { text: "hi" }].each { |input| refute_nil @client.embed(input)["error"] }
|
|
96
|
+
[{ dimensions: 0 }, { dimensions: "2" }, { encoding_format: "base64" }, { model: "bad/path" }].each do |kwargs|
|
|
97
|
+
refute_nil @client.embed("Hello", **kwargs)["error"]
|
|
98
|
+
end
|
|
99
|
+
assert_nil http.request_received
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_api_transport_and_malformed_response_errors
|
|
104
|
+
with_http({ "error" => { "message" => "Denied" } }, "403") do
|
|
105
|
+
result = @client.embed("Hello", debug: true)
|
|
106
|
+
assert_equal 403, result["status"]
|
|
107
|
+
assert_equal "Denied", result["error"]
|
|
108
|
+
refute_nil result["raw"]
|
|
109
|
+
end
|
|
110
|
+
["invalid json", [], {}, { "embedding" => { "values" => ["bad"] } }, IOError.new("Disconnected")].each do |raw|
|
|
111
|
+
with_http(raw) do
|
|
112
|
+
result = @client.embed("Hello")
|
|
113
|
+
refute_nil result["error"]
|
|
114
|
+
assert_nil result["content"]
|
|
115
|
+
assert_nil result["raw"]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
with_http({ "embeddings" => [{ "values" => [1] }] }) do
|
|
119
|
+
assert_match(/count/, @client.embed(["one", "two"])["error"])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
|
|
4
|
+
class GeminiImagesTest < Minitest::Test
|
|
5
|
+
Response = Struct.new(:code, :body)
|
|
6
|
+
class Http
|
|
7
|
+
attr_accessor :open_timeout, :read_timeout
|
|
8
|
+
attr_reader :request_received
|
|
9
|
+
def initialize(response)
|
|
10
|
+
@response = response
|
|
11
|
+
end
|
|
12
|
+
def request(request)
|
|
13
|
+
@request_received = request
|
|
14
|
+
raise @response if @response.is_a?(Exception)
|
|
15
|
+
@response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def setup
|
|
20
|
+
AiLite::Gemini.reset_configuration!
|
|
21
|
+
AiLite::OpenAI.reset_configuration!
|
|
22
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_http(raw = {}, status = "200")
|
|
26
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
27
|
+
http = Http.new(response)
|
|
28
|
+
original = Net::HTTP.method(:start)
|
|
29
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
30
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
31
|
+
block.call(http)
|
|
32
|
+
end
|
|
33
|
+
yield http
|
|
34
|
+
ensure
|
|
35
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
PNG = Base64.strict_decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=")
|
|
39
|
+
|
|
40
|
+
def image_body
|
|
41
|
+
{ "id" => "image-123", "status" => "completed", "steps" => [
|
|
42
|
+
{ "type" => "thought", "content" => [{ "type" => "image", "data" => Base64.strict_encode64("hidden") }] },
|
|
43
|
+
{ "type" => "model_output", "content" => [
|
|
44
|
+
{ "type" => "text", "text" => "Here is your image" },
|
|
45
|
+
{ "type" => "image", "mime_type" => "image/png", "data" => Base64.strict_encode64(PNG) },
|
|
46
|
+
{ "type" => "image", "mime_type" => "image/png", "data" => Base64.strict_encode64("second") }
|
|
47
|
+
] }
|
|
48
|
+
] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_default_transport_first_image_and_envelope
|
|
52
|
+
with_http(image_body) do |http|
|
|
53
|
+
result = @client.image("A cat")
|
|
54
|
+
assert_equal({ "content" => Base64.strict_encode64(PNG), "status" => 200,
|
|
55
|
+
"error" => nil, "response_id" => "image-123", "raw" => nil }, result)
|
|
56
|
+
request = http.request_received
|
|
57
|
+
assert_equal "/v1beta/interactions", request.path
|
|
58
|
+
assert_equal "gemini-key", request["x-goog-api-key"]
|
|
59
|
+
assert_equal "application/json", request["Content-Type"]
|
|
60
|
+
assert_equal({ "model" => AiLite::Gemini::DEFAULT_IMAGE_MODEL, "input" => "A cat" }, JSON.parse(request.body))
|
|
61
|
+
assert_equal 9, http.open_timeout
|
|
62
|
+
assert_equal 9, http.read_timeout
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_native_options_precedence_debug_and_file_output
|
|
67
|
+
options = { model: "ignored", "input" => "ignored", response_format: { type: "image", aspect_ratio: "16:9", image_size: "2K" }, store: false }
|
|
68
|
+
original = Marshal.dump(options)
|
|
69
|
+
Dir.mktmpdir do |dir|
|
|
70
|
+
path = File.join(dir, "cat.png")
|
|
71
|
+
with_http(image_body) do |http|
|
|
72
|
+
result = @client.image("A cat", model: "custom-image", output_path: path, options: options, debug: true)
|
|
73
|
+
assert_nil result["error"]
|
|
74
|
+
assert_equal PNG, File.binread(path)
|
|
75
|
+
assert_equal Base64.strict_encode64(PNG), result["content"]
|
|
76
|
+
assert_equal image_body, result["raw"]
|
|
77
|
+
payload = JSON.parse(http.request_received.body)
|
|
78
|
+
assert_equal "custom-image", payload["model"]
|
|
79
|
+
assert_equal "A cat", payload["input"]
|
|
80
|
+
assert_equal false, payload["store"]
|
|
81
|
+
assert_equal "16:9", payload.dig("response_format", "aspect_ratio")
|
|
82
|
+
assert_equal "2K", payload.dig("response_format", "image_size")
|
|
83
|
+
refute payload.key?("generation_config")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
assert_equal original, Marshal.dump(options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_configuration_delegation_and_model_checks
|
|
90
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.image_model = "configured-image" }
|
|
91
|
+
assert_equal "configured-image", AiLite::Gemini.client.image_model
|
|
92
|
+
assert_equal AiLite::Gemini::DEFAULT_IMAGE_MODEL, @client.image_model
|
|
93
|
+
assert_equal "override", AiLite::Gemini.new(image_model: "override").image_model
|
|
94
|
+
original = AiLite::OpenAI.configured_models
|
|
95
|
+
snapshot = AiLite::Gemini.configured_models
|
|
96
|
+
snapshot["image"].replace("changed")
|
|
97
|
+
assert_equal "configured-image", AiLite::Gemini.configuration.image_model
|
|
98
|
+
with_http(image_body) do |http|
|
|
99
|
+
assert_nil AiLite::Gemini.image("A cat")["error"]
|
|
100
|
+
assert_equal "configured-image", JSON.parse(http.request_received.body)["model"]
|
|
101
|
+
end
|
|
102
|
+
with_http({ "models" => [{ "name" => "models/configured-image" }] }) do
|
|
103
|
+
assert_equal true, AiLite::Gemini.check_configured_models.dig("content", "image", "available")
|
|
104
|
+
end
|
|
105
|
+
assert_equal original, AiLite::OpenAI.configured_models
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_invalid_inputs_and_execution_modes_do_not_send_requests
|
|
109
|
+
with_http do |http|
|
|
110
|
+
[nil, " ", [], {}].each { |prompt| refute_nil @client.image(prompt)["error"] }
|
|
111
|
+
[{ stream: true }, { "background" => true }].each do |options|
|
|
112
|
+
refute_nil @client.image("A cat", options: options)["error"]
|
|
113
|
+
end
|
|
114
|
+
assert_nil http.request_received
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_errors_do_not_overwrite_output
|
|
119
|
+
malformed = image_body
|
|
120
|
+
malformed["steps"][1]["content"][1]["data"] = "invalid base64!"
|
|
121
|
+
no_image = image_body.merge("steps" => [{ "type" => "model_output", "content" => [{ "type" => "text", "text" => "Cannot generate" }] }])
|
|
122
|
+
responses = [malformed, no_image, "invalid JSON", [], {}, IOError.new("Disconnected")]
|
|
123
|
+
%w[failed incomplete cancelled requires_action queued in_progress].each do |state|
|
|
124
|
+
responses << image_body.merge("status" => state)
|
|
125
|
+
end
|
|
126
|
+
Dir.mktmpdir do |dir|
|
|
127
|
+
path = File.join(dir, "existing.png")
|
|
128
|
+
File.binwrite(path, "original")
|
|
129
|
+
responses.each do |raw|
|
|
130
|
+
with_http(raw) do
|
|
131
|
+
result = @client.image("A cat", output_path: path)
|
|
132
|
+
refute_nil result["error"]
|
|
133
|
+
assert_nil result["content"]
|
|
134
|
+
assert_nil result["raw"]
|
|
135
|
+
assert_equal "original", File.binread(path)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
with_http(image_body) do
|
|
139
|
+
result = @client.image("A cat", output_path: File.join(dir, "missing", "cat.png"), debug: true)
|
|
140
|
+
refute_nil result["error"]
|
|
141
|
+
assert_equal "image-123", result["response_id"]
|
|
142
|
+
assert_equal image_body, result["raw"]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
["403", "200"].each do |status|
|
|
146
|
+
with_http({ "error" => { "message" => "Denied" } }, status) do
|
|
147
|
+
result = @client.image("A cat", debug: true)
|
|
148
|
+
assert_equal "Denied", result["error"]
|
|
149
|
+
assert_equal status.to_i, result["status"]
|
|
150
|
+
refute_nil result["raw"]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
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, "embedding" => AiLite::Gemini::DEFAULT_EMBEDDING_MODEL, "image" => AiLite::Gemini::DEFAULT_IMAGE_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"].slice("chat"))
|
|
78
|
+
assert_equal false, result.dig("content", "embedding", "available")
|
|
79
|
+
assert_nil result["raw"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
def test_failed_later_pages_do_not_return_partial_success
|
|
83
|
+
[["200", { "error" => { "message" => "Unexpected error" } }], ["403", { "error" => { "message" => "Denied" } }], ["200", "invalid"],
|
|
84
|
+
["200", { "models" => [{}] }], ["200", []], ["200", { "nextPageToken" => 3 }],
|
|
85
|
+
IOError.new("Disconnected")].each do |failure|
|
|
86
|
+
with_pages(page(["test-model"], "next"), failure) do
|
|
87
|
+
result = @client.available_models(debug: true)
|
|
88
|
+
assert_nil result["content"]
|
|
89
|
+
refute_nil result["error"]
|
|
90
|
+
refute_empty result.dig("raw", "pages")
|
|
91
|
+
end
|
|
92
|
+
with_pages(failure) { assert_nil @client.model_available?("test-model") }
|
|
93
|
+
with_pages(failure) do
|
|
94
|
+
result = @client.check_configured_models
|
|
95
|
+
assert_nil result.dig("content", "chat", "available")
|
|
96
|
+
refute_nil result["error"]
|
|
97
|
+
assert_equal result["error"], result.dig("content", "chat", "error")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
with_pages(page([], "same"), page([], "same")) do |http|
|
|
101
|
+
assert_match(/Repeated/, @client.available_models["error"])
|
|
102
|
+
assert_equal 2, http.requests.size
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
def test_class_delegation_and_provider_isolation
|
|
106
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.model = "test-model" }
|
|
107
|
+
original = AiLite::OpenAI.configured_models
|
|
108
|
+
with_pages(page(["test-model"])) { assert_equal ["test-model"], AiLite::Gemini.available_models["content"] }
|
|
109
|
+
with_pages(page(["test-model"])) { assert_equal true, AiLite::Gemini.model_available?("test-model") }
|
|
110
|
+
with_pages(page([])) { assert_equal false, AiLite::Gemini.check_configured_models.dig("content", "chat", "available") }
|
|
111
|
+
assert_equal original, AiLite::OpenAI.configured_models
|
|
112
|
+
end
|
|
113
|
+
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
|