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.
@@ -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,54 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.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-09-16 00:00:00.000000000 Z
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 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, 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: Minimal Ruby client for simple OpenAI API calls.
70
+ summary: Lightweight Ruby client with separate OpenAI and Gemini providers.
50
71
  test_files: []