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
data/test/gemini_test.rb
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
class GeminiTest < Minitest::Test
|
|
6
|
+
Response = Struct.new(:code, :body)
|
|
7
|
+
class Http
|
|
8
|
+
attr_accessor :open_timeout, :read_timeout
|
|
9
|
+
attr_reader :request_received
|
|
10
|
+
def initialize(response)
|
|
11
|
+
@response = response
|
|
12
|
+
end
|
|
13
|
+
def request(request)
|
|
14
|
+
@request_received = request
|
|
15
|
+
raise @response if @response.is_a?(Exception)
|
|
16
|
+
@response
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup
|
|
21
|
+
AiLite::Gemini.reset_configuration!
|
|
22
|
+
AiLite::OpenAI.reset_configuration!
|
|
23
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def body(text = "Hello")
|
|
27
|
+
{ "id" => "interaction_123", "status" => "completed", "steps" => [
|
|
28
|
+
{ "type" => "thought", "content" => [{ "type" => "text", "text" => "Hidden" }] },
|
|
29
|
+
{ "type" => "model_output", "content" => [{ "type" => "text", "text" => text }] }
|
|
30
|
+
], "usage" => { "total_tokens" => 10 } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_http(raw = body, status = "200")
|
|
34
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
35
|
+
http = Http.new(response)
|
|
36
|
+
original = Net::HTTP.method(:start)
|
|
37
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
38
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
39
|
+
block.call(http)
|
|
40
|
+
end
|
|
41
|
+
yield http
|
|
42
|
+
ensure
|
|
43
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_string_chat_transport_and_envelope
|
|
47
|
+
with_http do |http|
|
|
48
|
+
result = @client.chat("Tell me a joke")
|
|
49
|
+
assert_equal({ "content" => "Hello", "response_id" => "interaction_123", "status" => 200,
|
|
50
|
+
"error" => nil, "raw" => nil }, result)
|
|
51
|
+
request = http.request_received
|
|
52
|
+
assert_equal "/v1beta/interactions", request.path
|
|
53
|
+
assert_equal "gemini-key", request["x-goog-api-key"]
|
|
54
|
+
assert_nil request["Authorization"]
|
|
55
|
+
assert_nil URI(request.path).query
|
|
56
|
+
payload = JSON.parse(request.body)
|
|
57
|
+
assert_equal "Tell me a joke", payload["input"]
|
|
58
|
+
assert_equal AiLite::Gemini::DEFAULT_MODEL, payload["model"]
|
|
59
|
+
assert_equal 2000, payload.dig("generation_config", "max_output_tokens")
|
|
60
|
+
assert_equal 9, http.open_timeout
|
|
61
|
+
assert_equal 9, http.read_timeout
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_structured_inputs_and_history_pass_through_without_mutation
|
|
66
|
+
inputs = [
|
|
67
|
+
{ type: "video", uri: "https://www.youtube.com/watch?v=example" },
|
|
68
|
+
[{ type: "video", uri: "https://www.youtube.com/watch?v=example" }, { type: "text", text: "Summarize" }],
|
|
69
|
+
[{ type: "user_input", content: [{ type: "text", text: "Hello" }] },
|
|
70
|
+
{ type: "model_output", content: [{ type: "text", text: "Hi" }] }]
|
|
71
|
+
]
|
|
72
|
+
inputs.each do |input|
|
|
73
|
+
original = Marshal.dump(input)
|
|
74
|
+
with_http do |http|
|
|
75
|
+
assert_nil @client.chat(input)["error"]
|
|
76
|
+
assert_equal JSON.parse(JSON.generate(input)), JSON.parse(http.request_received.body)["input"]
|
|
77
|
+
assert_equal original, Marshal.dump(input)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_keywords_and_provider_options
|
|
83
|
+
options = { "generation_config" => { "temperature" => 0.4, "max_output_tokens" => 50 },
|
|
84
|
+
"previous_interaction_id" => "old", store: false, response_format: { type: "object" } }
|
|
85
|
+
original = Marshal.dump(options)
|
|
86
|
+
with_http(body('{"ok":true}')) do |http|
|
|
87
|
+
result = @client.chat("Hello", model: "custom-model", instructions: "Be brief",
|
|
88
|
+
previous_response_id: "previous", max_output_tokens: 25, debug: true, options: options)
|
|
89
|
+
payload = JSON.parse(http.request_received.body)
|
|
90
|
+
assert_equal "custom-model", payload["model"]
|
|
91
|
+
assert_equal "Be brief", payload["system_instruction"]
|
|
92
|
+
assert_equal "previous", payload["previous_interaction_id"]
|
|
93
|
+
refute payload.key?("previous_response_id")
|
|
94
|
+
assert_equal({ "temperature" => 0.4, "max_output_tokens" => 25 }, payload["generation_config"])
|
|
95
|
+
assert_equal false, payload["store"]
|
|
96
|
+
assert_equal({ "ok" => true }, result["content"])
|
|
97
|
+
assert_equal 10, result.dig("raw", "usage", "total_tokens")
|
|
98
|
+
assert_equal original, Marshal.dump(options)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_options_continuation_and_token_limit_without_keywords
|
|
103
|
+
with_http do |http|
|
|
104
|
+
@client.chat("Hi", options: { previous_interaction_id: "before", generation_config: { max_output_tokens: 33 } })
|
|
105
|
+
payload = JSON.parse(http.request_received.body)
|
|
106
|
+
assert_equal "before", payload["previous_interaction_id"]
|
|
107
|
+
assert_equal 33, payload.dig("generation_config", "max_output_tokens")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_config_and_cached_clients_are_isolated
|
|
112
|
+
AiLite::OpenAI.configure { |config| config.api_key = "openai-key"; config.model = "openai-model" }
|
|
113
|
+
openai = AiLite::OpenAI.client
|
|
114
|
+
AiLite::Gemini.configure do |config|
|
|
115
|
+
config.api_key = "configured-gemini"
|
|
116
|
+
config.model = "configured-model"
|
|
117
|
+
config.timeout = 14
|
|
118
|
+
config.max_output_tokens = 88
|
|
119
|
+
end
|
|
120
|
+
gemini = AiLite::Gemini.client
|
|
121
|
+
assert_same gemini, AiLite::Gemini.client
|
|
122
|
+
assert_equal "configured-gemini", gemini.api_key
|
|
123
|
+
assert_equal "configured-model", gemini.model
|
|
124
|
+
assert_equal 14, gemini.timeout
|
|
125
|
+
assert_equal 88, gemini.max_output_tokens
|
|
126
|
+
overridden = AiLite::Gemini.new(api_key: "explicit", model: "override", timeout: 3, max_output_tokens: 4)
|
|
127
|
+
assert_equal ["explicit", "override", 3, 4], [overridden.api_key, overridden.model, overridden.timeout, overridden.max_output_tokens]
|
|
128
|
+
AiLite::Gemini.configure { |config| config.api_key = "new-key" }
|
|
129
|
+
refute_same gemini, AiLite::Gemini.client
|
|
130
|
+
assert_same openai, AiLite::OpenAI.client
|
|
131
|
+
assert_equal "openai-key", openai.api_key
|
|
132
|
+
refute AiLite::Gemini < AiLite::OpenAI
|
|
133
|
+
%i[moderate transcribe 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
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:
|
|
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-
|
|
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
|
|
15
|
-
transcription
|
|
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:
|
|
75
|
+
summary: Lightweight Ruby client with separate OpenAI and Gemini providers.
|
|
50
76
|
test_files: []
|