ai-lite 0.6.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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