ai-lite 0.6.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +386 -12
- data/lib/ai_lite/gemini/chat.rb +78 -0
- data/lib/ai_lite/gemini/configuration.rb +19 -0
- data/lib/ai_lite/gemini/models.rb +102 -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 +92 -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 -623
- data/test/ai_lite_test.rb +242 -73
- data/test/gemini_models_test.rb +112 -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 +27 -6
|
@@ -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,54 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ai-lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.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-
|
|
11
|
+
date: 2026-10-10 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, structured media inputs, and model checks.
|
|
16
17
|
email:
|
|
17
18
|
executables: []
|
|
18
19
|
extensions: []
|
|
19
20
|
extra_rdoc_files: []
|
|
20
21
|
files:
|
|
22
|
+
- CHANGELOG.md
|
|
21
23
|
- LICENSE
|
|
22
24
|
- README.md
|
|
23
25
|
- lib/ai_lite.rb
|
|
26
|
+
- lib/ai_lite/gemini.rb
|
|
27
|
+
- lib/ai_lite/gemini/chat.rb
|
|
28
|
+
- lib/ai_lite/gemini/configuration.rb
|
|
29
|
+
- lib/ai_lite/gemini/models.rb
|
|
30
|
+
- lib/ai_lite/gemini/streaming.rb
|
|
31
|
+
- lib/ai_lite/gemini/youtube.rb
|
|
32
|
+
- lib/ai_lite/openai.rb
|
|
33
|
+
- lib/ai_lite/openai/configuration.rb
|
|
34
|
+
- lib/ai_lite/openai/models.rb
|
|
35
|
+
- lib/ai_lite/openai/streaming.rb
|
|
24
36
|
- lib/ai_lite/version.rb
|
|
25
37
|
- test/ai_lite_test.rb
|
|
38
|
+
- test/gemini_models_test.rb
|
|
39
|
+
- test/gemini_streaming_test.rb
|
|
40
|
+
- test/gemini_test.rb
|
|
41
|
+
- test/gemini_youtube_test.rb
|
|
42
|
+
- test/openai_models_test.rb
|
|
43
|
+
- test/openai_streaming_test.rb
|
|
44
|
+
- test/run.rb
|
|
26
45
|
- test/test_helper.rb
|
|
27
46
|
homepage: https://github.com/wbasmayor/ai-lite
|
|
28
47
|
licenses:
|
|
29
48
|
- MIT
|
|
30
|
-
metadata:
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://github.com/wbasmayor/ai-lite
|
|
51
|
+
changelog_uri: https://github.com/wbasmayor/ai-lite/blob/main/CHANGELOG.md
|
|
31
52
|
post_install_message:
|
|
32
53
|
rdoc_options: []
|
|
33
54
|
require_paths:
|
|
@@ -46,5 +67,5 @@ requirements: []
|
|
|
46
67
|
rubygems_version: 3.5.22
|
|
47
68
|
signing_key:
|
|
48
69
|
specification_version: 4
|
|
49
|
-
summary:
|
|
70
|
+
summary: Lightweight Ruby client with separate OpenAI and Gemini providers.
|
|
50
71
|
test_files: []
|