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,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 speak].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
@@ -0,0 +1,189 @@
1
+ require_relative "test_helper"
2
+
3
+ class OpenAIStreamingTest < Minitest::Test
4
+ class Response
5
+ attr_reader :code, :read_count
6
+ def initialize(chunks, code = "200")
7
+ @chunks, @code, @read_count = chunks, code, 0
8
+ end
9
+
10
+ def read_body
11
+ @chunks.each do |chunk|
12
+ raise chunk if chunk.is_a?(Exception)
13
+ @read_count += 1
14
+ yield chunk
15
+ end
16
+ end
17
+ end
18
+
19
+ class Http
20
+ attr_accessor :open_timeout, :read_timeout
21
+ attr_reader :request_received
22
+ def initialize(response)
23
+ @response = response
24
+ end
25
+ def request(request)
26
+ @request_received = request
27
+ yield @response
28
+ end
29
+ end
30
+
31
+ def setup
32
+ AiLite::OpenAI.reset_configuration!
33
+ @client = AiLite::OpenAI.new(api_key: "test-key", timeout: 17)
34
+ end
35
+
36
+ def event(type, fields = {})
37
+ "event: #{type}\ndata: #{JSON.generate(fields.merge('type' => type))}\n\n"
38
+ end
39
+
40
+ def completed(text = "Hello")
41
+ event("response.completed", "response" => {
42
+ "id" => "resp_test", "status" => "completed", "usage" => { "output_tokens" => 2 },
43
+ "output" => [{ "type" => "message", "content" => [{ "type" => "output_text", "text" => text }] }]
44
+ })
45
+ end
46
+
47
+ def with_http(chunks, code = "200")
48
+ response = Response.new(chunks, code)
49
+ http = Http.new(response)
50
+ original = Net::HTTP.method(:start)
51
+ Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
52
+ raise "Wrong endpoint" unless host == "api.openai.com" && port == 443 && use_ssl
53
+ block.call(http)
54
+ end
55
+ yield http, response
56
+ ensure
57
+ Net::HTTP.define_singleton_method(:start, original)
58
+ end
59
+
60
+ def test_yields_before_reading_next_chunk_and_preserves_chat_options
61
+ chunks = [event("response.output_text.delta", "delta" => "Hel"),
62
+ event("response.output_text.delta", "delta" => "lo"), completed]
63
+ with_http(chunks) do |http, response|
64
+ yielded = []
65
+ result = @client.chat_stream([{ role: "user", content: "Hi" }], model: "test-model",
66
+ instructions: "Be brief", previous_response_id: "resp_before", max_output_tokens: 9,
67
+ options: { "stream" => false, stream: false, temperature: 0.5 }, debug: true) do |delta|
68
+ yielded << delta
69
+ assert_equal yielded.length, response.read_count
70
+ end
71
+ assert_equal ["Hel", "lo"], yielded
72
+ assert_equal "Hello", result["content"]
73
+ assert_equal "resp_test", result["response_id"]
74
+ assert_equal 200, result["status"]
75
+ assert_nil result["error"]
76
+ assert_equal 2, result.dig("raw", "usage", "output_tokens")
77
+ request = http.request_received
78
+ payload = JSON.parse(request.body)
79
+ assert_equal "/v1/responses", request.path
80
+ assert_equal "Bearer test-key", request["Authorization"]
81
+ assert_equal "text/event-stream", request["Accept"]
82
+ assert_equal true, payload["stream"]
83
+ assert_equal "test-model", payload["model"]
84
+ assert_equal "Be brief", payload["instructions"]
85
+ assert_equal "resp_before", payload["previous_response_id"]
86
+ assert_equal 9, payload["max_output_tokens"]
87
+ assert_equal 0.5, payload["temperature"]
88
+ assert_equal [{ "role" => "user", "content" => "Hi" }], payload["input"]
89
+ assert_equal 17, http.open_timeout
90
+ assert_equal 17, http.read_timeout
91
+ end
92
+ end
93
+
94
+ def test_byte_splits_crlf_comments_multiline_data_and_ignored_events
95
+ stream = ": heartbeat\r\n\r\n" +
96
+ "data: {\"type\":\"response.output_text.delta\",\r\ndata: \"delta\":\"Hi 🌍\"}\r\n\r\n" +
97
+ event("response.output_text.done", "text" => "Hi 🌍") + completed("Hi 🌍")
98
+ with_http(stream.bytes.map { |byte| byte.chr }) do
99
+ parts = []
100
+ result = @client.chat_stream("Hi") { |delta| parts << delta }
101
+ assert_equal ["Hi 🌍"], parts
102
+ assert_equal "Hi 🌍", result["content"]
103
+ assert_nil result["raw"]
104
+ assert_nil result["error"]
105
+ end
106
+ end
107
+
108
+ def test_json_final_content_and_multiple_events_in_one_chunk
109
+ with_http([event("response.output_text.delta", "delta" => '{"ok":true}') + completed('{"ok":true}')]) do
110
+ parts = []
111
+ result = @client.chat_stream("Hi") { |delta| parts << delta }
112
+ assert_equal ['{"ok":true}'], parts
113
+ assert_equal({ "ok" => true }, result["content"])
114
+ end
115
+ end
116
+
117
+ def test_failed_incomplete_and_error_events_preserve_partial_text
118
+ terminals = [
119
+ [event("response.failed", "response" => { "id" => "resp_test", "error" => { "message" => "Failed" } }), "Failed"],
120
+ [event("response.incomplete", "response" => { "id" => "resp_test", "incomplete_details" => { "reason" => "max_output_tokens" } }), "Response incomplete: max_output_tokens"],
121
+ [event("error", "message" => "Rate limited"), "Rate limited"]
122
+ ]
123
+ terminals.each do |terminal, message|
124
+ with_http([event("response.created", "response" => { "id" => "resp_test" }),
125
+ event("response.output_text.delta", "delta" => "Partial"), terminal]) do
126
+ result = @client.chat_stream("Hi", debug: true) { |_| }
127
+ assert_equal "Partial", result["content"]
128
+ assert_equal message, result["error"]
129
+ assert_equal "resp_test", result["response_id"]
130
+ refute_nil result["raw"]
131
+ end
132
+ end
133
+ end
134
+
135
+ def test_truncation_done_marker_malformed_json_and_disconnect_are_errors
136
+ [[], ["data: [DONE]\n\n"], ["data: {bad}\n\n"], ["data: {"], [IOError.new("Connection lost")]].each do |tail|
137
+ with_http([event("response.output_text.delta", "delta" => "Partial")] + tail) do
138
+ result = @client.chat_stream("Hi") { |_| }
139
+ assert_equal "Partial", result["content"]
140
+ refute_nil result["error"]
141
+ end
142
+ end
143
+ end
144
+
145
+ def test_http_errors_do_not_yield
146
+ ['{"error":{"message":"Invalid API key"}}', "Bad gateway"].each do |body|
147
+ with_http([body], "401") do
148
+ result = @client.chat_stream("Hi", debug: true) { flunk "Should not yield" }
149
+ assert_equal 401, result["status"]
150
+ refute_nil result["error"]
151
+ refute_nil result["raw"]
152
+ end
153
+ end
154
+ end
155
+
156
+ def test_missing_block_and_callback_errors_raise
157
+ assert_raises(ArgumentError) { @client.chat_stream("Hi") }
158
+ with_http([event("response.output_text.delta", "delta" => "Hello")]) do
159
+ error = RuntimeError.new("UI failed")
160
+ raised = assert_raises(RuntimeError) { @client.chat_stream("Hi") { raise error } }
161
+ assert_same error, raised
162
+ end
163
+ end
164
+
165
+ def test_mixed_sse_newlines_and_lone_cr_at_eof
166
+ stream = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello\"}\r\n\n" + completed.gsub("\n", "\r")
167
+ with_http(stream.bytes.map(&:chr)) do
168
+ parts = []
169
+ result = @client.chat_stream("Hi") { |delta| parts << delta }
170
+ assert_equal ["Hello"], parts
171
+ assert_equal "Hello", result["content"]
172
+ assert_nil result["error"]
173
+ end
174
+ end
175
+
176
+ def test_provider_and_legacy_class_methods_forward_blocks
177
+ AiLite::OpenAI.configure { |config| config.api_key = "test-key" }
178
+ [AiLite::OpenAI, AiLite].each do |provider|
179
+ with_http([event("response.output_text.delta", "delta" => "Hello"), completed]) do
180
+ parts = []
181
+ capture_io do
182
+ result = provider.chat_stream("Hi") { |delta| parts << delta }
183
+ assert_equal "Hello", result["content"]
184
+ end
185
+ assert_equal ["Hello"], parts
186
+ end
187
+ end
188
+ end
189
+ end
data/test/run.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, "*_test.rb")].sort.each { |file| require file }
metadata CHANGED
@@ -1,33 +1,59 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - William Basmayor
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: AI Lite is a dependency-light Ruby client for Rails apps and plain Ruby
14
- projects that need simple OpenAI chat, moderation, embedding, image, speech, and
15
- transcription calls.
14
+ projects, using only the Ruby standard library. It provides OpenAI chat, streaming,
15
+ moderation, embeddings, images, speech, transcription, and model checks, plus Gemini
16
+ chat, streaming, embeddings, image generation, structured media inputs, and model
17
+ checks.
16
18
  email:
17
19
  executables: []
18
20
  extensions: []
19
21
  extra_rdoc_files: []
20
22
  files:
23
+ - CHANGELOG.md
21
24
  - LICENSE
22
25
  - README.md
23
26
  - lib/ai_lite.rb
27
+ - lib/ai_lite/gemini.rb
28
+ - lib/ai_lite/gemini/chat.rb
29
+ - lib/ai_lite/gemini/configuration.rb
30
+ - lib/ai_lite/gemini/embeddings.rb
31
+ - lib/ai_lite/gemini/images.rb
32
+ - lib/ai_lite/gemini/models.rb
33
+ - lib/ai_lite/gemini/streaming.rb
34
+ - lib/ai_lite/gemini/youtube.rb
35
+ - lib/ai_lite/openai.rb
36
+ - lib/ai_lite/openai/configuration.rb
37
+ - lib/ai_lite/openai/models.rb
38
+ - lib/ai_lite/openai/streaming.rb
24
39
  - lib/ai_lite/version.rb
25
40
  - test/ai_lite_test.rb
41
+ - test/gemini_embeddings_test.rb
42
+ - test/gemini_images_test.rb
43
+ - test/gemini_models_test.rb
44
+ - test/gemini_streaming_test.rb
45
+ - test/gemini_test.rb
46
+ - test/gemini_youtube_test.rb
47
+ - test/openai_models_test.rb
48
+ - test/openai_streaming_test.rb
49
+ - test/run.rb
26
50
  - test/test_helper.rb
27
51
  homepage: https://github.com/wbasmayor/ai-lite
28
52
  licenses:
29
53
  - MIT
30
- metadata: {}
54
+ metadata:
55
+ source_code_uri: https://github.com/wbasmayor/ai-lite
56
+ changelog_uri: https://github.com/wbasmayor/ai-lite/blob/main/CHANGELOG.md
31
57
  post_install_message:
32
58
  rdoc_options: []
33
59
  require_paths:
@@ -46,5 +72,5 @@ requirements: []
46
72
  rubygems_version: 3.5.22
47
73
  signing_key:
48
74
  specification_version: 4
49
- summary: Minimal Ruby client for simple OpenAI API calls.
75
+ summary: Lightweight Ruby client with separate OpenAI and Gemini providers.
50
76
  test_files: []