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.
@@ -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
@@ -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