ai-lite 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b1ae1b32c854b4f99a6fd8275a375c3d1870c93e5507756c9e6c3089ee220ea
4
- data.tar.gz: fafd0e42607277bead469d0628d106af48cf7334ebecb100ca60b5ae5074b47a
3
+ metadata.gz: cd1b2ead0a8ae48d9617b9e73460649f817fc158430e11cc7b9ac798a86846d2
4
+ data.tar.gz: f3dad03f56f3907e537dc8c72d63bf7e57e069d4b28ef666151d348b98f6b3e6
5
5
  SHA512:
6
- metadata.gz: eae14b861938c6a351a1b0430eb8ec03569a41fec7896af012addfb2fb5e4f60d6a28345c2cde6c1b82270ccd40cda21da9db470fa7694a2368930ab3e1f1072
7
- data.tar.gz: a31f91cf5db94e669f992b03added0312d287aeb991b327adc3f7703d8cc582e460d26d3e7e2c4692a3a29693f2de6cf9998b4e59be593e7a64569b6fb66758d
6
+ metadata.gz: 6e70409efd1d697b2578ace5b878e74ef4e6214efbcbae2c295aae066e0e9ce4887cd39c95fcdde8bc73e0f6b0cfe60434dc7f1ca2611c77e649cc645b94351a
7
+ data.tar.gz: 1faba64c32c14038f18c1d50a2f73bd92a0c940d8b303b3eecb89c2193d944d3b189334c5ecde81f55e8268db01e01e562686b74f20111105f0c0fbbeea387c6
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AI Lite
2
2
 
3
- AI Lite is a pure Ruby, dependency-light client for simple chat-style calls through OpenAI's Responses API.
3
+ AI Lite is a pure Ruby, dependency-light client for simple OpenAI API calls.
4
4
 
5
5
  It is built for Rails apps and plain Ruby projects where installing a full OpenAI SDK is too heavy, incompatible, or unnecessary. It is especially useful in legacy Rails apps where Rails, ActiveSupport, Ruby, or HTTP-client dependency constraints make larger client libraries difficult to add.
6
6
 
@@ -11,12 +11,14 @@ This gem is intentionally small:
11
11
  - No Rails dependency
12
12
  - No official OpenAI gem dependency
13
13
  - No Faraday, HTTParty, ActiveSupport, or connection pool dependency
14
- - Uses only Ruby stdlib: `Net::HTTP`, `URI`, and `JSON`
14
+ - Uses only Ruby stdlib: `Net::HTTP`, `URI`, `JSON`, and `Base64`
15
15
 
16
- It is not meant to replace the official OpenAI SDK. It is a small wrapper for projects that only need one clean interface:
16
+ It is not meant to replace the official OpenAI SDK. It is a small wrapper for projects that only need a few clean interfaces:
17
17
 
18
18
  ```ruby
19
19
  ai.chat("Say hello")
20
+ ai.moderate("User submitted text")
21
+ ai.embed("Text to vectorize")
20
22
  ```
21
23
 
22
24
  ## Usage
@@ -47,6 +49,8 @@ In Rails, configure the default client from an initializer:
47
49
  AiLite.configure do |config|
48
50
  config.api_key = ENV["OPENAI_API_KEY"]
49
51
  config.model = "gpt-5.5"
52
+ config.moderation_model = "omni-moderation-latest"
53
+ config.embedding_model = "text-embedding-3-small"
50
54
  config.timeout = 120
51
55
  config.max_output_tokens = 2000
52
56
  end
@@ -91,6 +95,150 @@ The default model is `gpt-5.5`.
91
95
 
92
96
  The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
93
97
 
98
+ ## Moderation
99
+
100
+ Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
101
+
102
+ A common use case is checking a comment before it is shown publicly:
103
+
104
+ ```ruby
105
+ comment = "User submitted comment"
106
+ result = ai.moderate(comment)
107
+
108
+ if result["content"]["flagged"]
109
+ # hide, block, or route the comment to review
110
+ else
111
+ # publish the comment
112
+ end
113
+ ```
114
+
115
+ Moderation responses include an overall `flagged` value plus category booleans and scores:
116
+
117
+ ```ruby
118
+ {
119
+ "content" => {
120
+ "flagged" => false,
121
+ "categories" => {
122
+ "hate" => false,
123
+ "violence" => false
124
+ },
125
+ "category_scores" => {
126
+ "hate" => 0.01,
127
+ "violence" => 0.02
128
+ }
129
+ },
130
+ "response_id" => "modr_...",
131
+ "status" => 200,
132
+ "error" => nil,
133
+ "raw" => nil
134
+ }
135
+ ```
136
+
137
+ `moderate` sends a `POST` request to `/v1/moderations` with:
138
+
139
+ - `model`
140
+ - `input`
141
+ - optional extra `options`
142
+
143
+ The default moderation model is `omni-moderation-latest`.
144
+
145
+ You can pass an image URL:
146
+
147
+ ```ruby
148
+ result = ai.moderate(
149
+ text: "Profile caption",
150
+ image_url: "https://example.com/image.png"
151
+ )
152
+ ```
153
+
154
+ Or a local image path:
155
+
156
+ ```ruby
157
+ result = ai.moderate(image_path: "tmp/upload.png")
158
+ ```
159
+
160
+ Local images are read, base64 encoded, and sent as JSON data URLs. They do not use multipart uploads. Supported local image extensions are `.gif`, `.jpeg`, `.jpg`, `.png`, and `.webp`.
161
+
162
+ You can also pass OpenAI's raw moderation input shape directly:
163
+
164
+ ```ruby
165
+ result = ai.moderate([
166
+ { type: "text", text: "Caption" },
167
+ {
168
+ type: "image_url",
169
+ image_url: {
170
+ url: "https://example.com/image.png"
171
+ }
172
+ }
173
+ ])
174
+ ```
175
+
176
+ ## Embeddings
177
+
178
+ Use `embed` to turn text into vectors that can be stored and compared for semantic search, recommendations, duplicate detection, or retrieval-augmented generation.
179
+
180
+ ```ruby
181
+ result = ai.embed("How do I reset my password?")
182
+ vector = result["content"]
183
+ ```
184
+
185
+ For one string input, `content` is the embedding vector:
186
+
187
+ ```ruby
188
+ {
189
+ "content" => [0.012, -0.44, 0.203],
190
+ "response_id" => nil,
191
+ "status" => 200,
192
+ "error" => nil,
193
+ "raw" => nil
194
+ }
195
+ ```
196
+
197
+ For multiple inputs, `content` is an array of vectors in the same order:
198
+
199
+ ```ruby
200
+ result = ai.embed([
201
+ "How do I reset my password?",
202
+ "How do I update my billing card?"
203
+ ])
204
+
205
+ vectors = result["content"]
206
+ first_vector = vectors[0]
207
+ ```
208
+
209
+ ```ruby
210
+ {
211
+ "content" => [
212
+ [0.012, -0.44, 0.203],
213
+ [0.332, 0.021, -0.118]
214
+ ],
215
+ "response_id" => nil,
216
+ "status" => 200,
217
+ "error" => nil,
218
+ "raw" => nil
219
+ }
220
+ ```
221
+
222
+ `embed` sends a `POST` request to `/v1/embeddings` with:
223
+
224
+ - `model`
225
+ - `input`
226
+ - optional `dimensions`
227
+ - optional `encoding_format`
228
+ - optional `debug`
229
+ - optional extra `options`
230
+
231
+ The default embedding model is `text-embedding-3-small`.
232
+
233
+ Pass `debug: true` to include the raw OpenAI response, including token usage:
234
+
235
+ ```ruby
236
+ result = ai.embed("Hello", debug: true)
237
+
238
+ result["content"] # embedding vector
239
+ result["raw"]["usage"] # token usage
240
+ ```
241
+
94
242
  ## Multi-Turn Chat
95
243
 
96
244
  Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
@@ -110,7 +258,7 @@ puts follow_up["content"]
110
258
 
111
259
  ## Return Shape
112
260
 
113
- `chat` always returns a hash envelope.
261
+ Methods return a hash envelope.
114
262
 
115
263
  Text output:
116
264
 
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.1.1".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "base64"
1
2
  require "json"
2
3
  require "net/http"
3
4
  require "uri"
@@ -6,15 +7,26 @@ require_relative "ai_lite/version"
6
7
  class AiLite
7
8
  API_BASE_URL = "https://api.openai.com/v1".freeze
8
9
  DEFAULT_MODEL = "gpt-5.5".freeze
10
+ DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
9
12
  DEFAULT_TIMEOUT = 120
10
13
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
14
+ IMAGE_MIME_TYPES = {
15
+ ".gif" => "image/gif",
16
+ ".jpeg" => "image/jpeg",
17
+ ".jpg" => "image/jpeg",
18
+ ".png" => "image/png",
19
+ ".webp" => "image/webp"
20
+ }.freeze
11
21
 
12
22
  class Configuration
13
- attr_accessor :api_key, :model, :timeout, :max_output_tokens
23
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens
14
24
 
15
25
  def initialize
16
26
  @api_key = nil
17
27
  @model = DEFAULT_MODEL
28
+ @moderation_model = DEFAULT_MODERATION_MODEL
29
+ @embedding_model = DEFAULT_EMBEDDING_MODEL
18
30
  @timeout = DEFAULT_TIMEOUT
19
31
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
20
32
  end
@@ -45,18 +57,28 @@ class AiLite
45
57
  client.chat(message, **kwargs)
46
58
  end
47
59
 
60
+ def moderate(input = nil, **kwargs)
61
+ client.moderate(input, **kwargs)
62
+ end
63
+
64
+ def embed(input, **kwargs)
65
+ client.embed(input, **kwargs)
66
+ end
67
+
48
68
  def reset_client!
49
69
  @client = nil
50
70
  end
51
71
  end
52
72
 
53
- attr_reader :api_key, :model, :timeout, :max_output_tokens, :headers
73
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens, :headers
54
74
 
55
- def initialize(api_key: nil, model: nil, timeout: nil, max_output_tokens: nil)
75
+ def initialize(api_key: nil, model: nil, moderation_model: nil, embedding_model: nil, timeout: nil, max_output_tokens: nil)
56
76
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
57
77
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
58
78
 
59
79
  @model = model || self.class.configuration.model
80
+ @moderation_model = moderation_model || self.class.configuration.moderation_model
81
+ @embedding_model = embedding_model || self.class.configuration.embedding_model
60
82
  @timeout = timeout || self.class.configuration.timeout
61
83
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
62
84
  @headers = {
@@ -78,10 +100,34 @@ class AiLite
78
100
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
79
101
  end
80
102
 
103
+ def moderate(input = nil, model: nil, text: nil, image_url: nil, image_path: nil, debug: false, options: {})
104
+ payload = options.merge(
105
+ model: model || moderation_model,
106
+ input: moderation_input(input, text: text, image_url: image_url, image_path: image_path)
107
+ )
108
+
109
+ extract_moderation(post(payload, endpoint: moderation_endpoint), debug: debug)
110
+ rescue => e
111
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
112
+ end
113
+
114
+ def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
115
+ payload = options.merge(
116
+ model: model || embedding_model,
117
+ input: input
118
+ )
119
+ payload[:dimensions] = dimensions if dimensions
120
+ payload[:encoding_format] = encoding_format if encoding_format
121
+
122
+ extract_embedding(post(payload, endpoint: embedding_endpoint), multiple: input.is_a?(Array), debug: debug)
123
+ rescue => e
124
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
125
+ end
126
+
81
127
  private
82
128
 
83
- def post(payload)
84
- uri = URI.parse(response_endpoint)
129
+ def post(payload, endpoint: response_endpoint)
130
+ uri = URI.parse(endpoint)
85
131
  request = Net::HTTP::Post.new(uri)
86
132
 
87
133
  headers.each do |key, value|
@@ -101,6 +147,14 @@ class AiLite
101
147
  "#{API_BASE_URL}/responses"
102
148
  end
103
149
 
150
+ def moderation_endpoint
151
+ "#{API_BASE_URL}/moderations"
152
+ end
153
+
154
+ def embedding_endpoint
155
+ "#{API_BASE_URL}/embeddings"
156
+ end
157
+
104
158
  def extract_content(response, debug: false)
105
159
  status = response.code.to_i
106
160
  parsed_response = JSON.parse(response.body)
@@ -130,6 +184,60 @@ class AiLite
130
184
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
131
185
  end
132
186
 
187
+ def extract_moderation(response, debug: false)
188
+ status = response.code.to_i
189
+ parsed_response = JSON.parse(response.body)
190
+
191
+ unless success_status?(status)
192
+ return prettify_data(
193
+ status: status,
194
+ error: error_message(parsed_response),
195
+ response_id: parsed_response["id"],
196
+ raw: parsed_response,
197
+ debug: debug
198
+ )
199
+ end
200
+
201
+ prettify_data(
202
+ status: status,
203
+ content: moderation_content(parsed_response),
204
+ response_id: parsed_response["id"],
205
+ raw: parsed_response,
206
+ debug: debug
207
+ )
208
+ rescue JSON::ParserError => e
209
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
210
+ rescue => e
211
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
212
+ end
213
+
214
+ def extract_embedding(response, multiple:, debug: false)
215
+ status = response.code.to_i
216
+ parsed_response = JSON.parse(response.body)
217
+
218
+ unless success_status?(status)
219
+ return prettify_data(
220
+ status: status,
221
+ error: error_message(parsed_response),
222
+ response_id: parsed_response["id"],
223
+ raw: parsed_response,
224
+ debug: debug
225
+ )
226
+ end
227
+
228
+ prettify_data(
229
+ status: status,
230
+ content: embedding_content(parsed_response, multiple: multiple),
231
+ response_id: parsed_response["id"],
232
+ raw: parsed_response,
233
+ debug: debug
234
+ )
235
+ rescue JSON::ParserError => e
236
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
237
+ rescue => e
238
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
239
+ end
240
+
133
241
  def extract_output_text(raw)
134
242
  Array(raw["output"]).flat_map do |item|
135
243
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -150,6 +258,60 @@ class AiLite
150
258
  raw_text
151
259
  end
152
260
 
261
+ def moderation_input(input, text:, image_url:, image_path:)
262
+ unless text || image_url || image_path
263
+ raise ArgumentError, "Missing moderation input" if input.nil?
264
+
265
+ return input
266
+ end
267
+
268
+ items = []
269
+ items.concat(Array(input).map { |value| moderation_input_item(value) }) unless input.nil?
270
+ items << { type: "text", text: text } if text
271
+ items << { type: "image_url", image_url: { url: image_url } } if image_url
272
+ items << { type: "image_url", image_url: { url: image_data_url(image_path) } } if image_path
273
+ raise ArgumentError, "Missing moderation input" if items.empty?
274
+
275
+ items
276
+ end
277
+
278
+ def moderation_input_item(value)
279
+ case value
280
+ when String
281
+ { type: "text", text: value }
282
+ when Hash
283
+ value
284
+ else
285
+ raise ArgumentError, "Unsupported moderation input item: #{value.class}"
286
+ end
287
+ end
288
+
289
+ def image_data_url(path)
290
+ mime_type = image_mime_type(path)
291
+ "data:#{mime_type};base64,#{Base64.strict_encode64(File.binread(path))}"
292
+ end
293
+
294
+ def image_mime_type(path)
295
+ IMAGE_MIME_TYPES.fetch(File.extname(path).downcase) do
296
+ raise ArgumentError, "Unsupported image type for moderation: #{File.extname(path)}"
297
+ end
298
+ end
299
+
300
+ def moderation_content(raw)
301
+ results = raw["results"]
302
+ return nil unless results.is_a?(Array)
303
+
304
+ results.length == 1 ? results.first : results
305
+ end
306
+
307
+ def embedding_content(raw, multiple:)
308
+ embeddings = Array(raw["data"]).map do |item|
309
+ item["embedding"] if item.is_a?(Hash)
310
+ end.compact
311
+
312
+ multiple ? embeddings : embeddings.first
313
+ end
314
+
153
315
  def success_status?(status)
154
316
  status >= 200 && status < 300
155
317
  end
data/test/ai_lite_test.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "test_helper"
4
+ require "tempfile"
4
5
 
5
6
  class AiLiteTest < Minitest::Test
6
7
  FakeResponse = Struct.new(:code, :body)
@@ -37,6 +38,8 @@ class AiLiteTest < Minitest::Test
37
38
 
38
39
  assert_equal "explicit-key", client.api_key
39
40
  assert_equal "gpt-test", client.model
41
+ assert_equal "omni-moderation-latest", client.moderation_model
42
+ assert_equal "text-embedding-3-small", client.embedding_model
40
43
  assert_equal 10, client.timeout
41
44
  assert_equal 2000, client.max_output_tokens
42
45
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -49,6 +52,8 @@ class AiLiteTest < Minitest::Test
49
52
  AiLite.configure do |config|
50
53
  config.api_key = "configured-key"
51
54
  config.model = "gpt-config"
55
+ config.moderation_model = "omni-moderation-test"
56
+ config.embedding_model = "text-embedding-test"
52
57
  config.timeout = 15
53
58
  config.max_output_tokens = 750
54
59
  end
@@ -57,6 +62,8 @@ class AiLiteTest < Minitest::Test
57
62
 
58
63
  assert_equal "configured-key", client.api_key
59
64
  assert_equal "gpt-config", client.model
65
+ assert_equal "omni-moderation-test", client.moderation_model
66
+ assert_equal "text-embedding-test", client.embedding_model
60
67
  assert_equal 15, client.timeout
61
68
  assert_equal 750, client.max_output_tokens
62
69
  assert_same client, AiLite.client
@@ -78,6 +85,8 @@ class AiLiteTest < Minitest::Test
78
85
  AiLite.configure do |config|
79
86
  config.api_key = "configured-key"
80
87
  config.model = "gpt-config"
88
+ config.moderation_model = "omni-moderation-config"
89
+ config.embedding_model = "text-embedding-config"
81
90
  config.timeout = 15
82
91
  config.max_output_tokens = 750
83
92
  end
@@ -85,12 +94,16 @@ class AiLiteTest < Minitest::Test
85
94
  client = AiLite.new(
86
95
  api_key: "explicit-key",
87
96
  model: "gpt-explicit",
97
+ moderation_model: "omni-moderation-explicit",
98
+ embedding_model: "text-embedding-explicit",
88
99
  timeout: 5,
89
100
  max_output_tokens: 300
90
101
  )
91
102
 
92
103
  assert_equal "explicit-key", client.api_key
93
104
  assert_equal "gpt-explicit", client.model
105
+ assert_equal "omni-moderation-explicit", client.moderation_model
106
+ assert_equal "text-embedding-explicit", client.embedding_model
94
107
  assert_equal 5, client.timeout
95
108
  assert_equal 300, client.max_output_tokens
96
109
  end
@@ -277,6 +290,241 @@ class AiLiteTest < Minitest::Test
277
290
  end
278
291
  end
279
292
 
293
+ def test_moderate_sends_post_to_moderations_with_default_payload
294
+ client = AiLite.new(api_key: "token-abc")
295
+
296
+ with_stubbed_http(moderation_response) do |captured, _response|
297
+ result = client.moderate("Some user submitted text")
298
+ request = captured[:http].last_request
299
+ payload = JSON.parse(request.body)
300
+
301
+ assert_equal false, result["content"]["flagged"]
302
+ assert_equal "modr_test_123", result["response_id"]
303
+ assert_equal 200, result["status"]
304
+ assert_nil result["error"]
305
+ assert_nil result["raw"]
306
+ assert_equal "api.openai.com", captured[:host]
307
+ assert_equal 443, captured[:port]
308
+ assert_equal true, captured[:use_ssl]
309
+ assert_instance_of Net::HTTP::Post, request
310
+ assert_equal "/v1/moderations", request.path
311
+ assert_equal "Bearer token-abc", request["Authorization"]
312
+ assert_equal "application/json", request["Content-Type"]
313
+ assert_equal "omni-moderation-latest", payload["model"]
314
+ assert_equal "Some user submitted text", payload["input"]
315
+ end
316
+ end
317
+
318
+ def test_moderate_uses_class_level_configured_client
319
+ AiLite.configure do |config|
320
+ config.api_key = "configured-key"
321
+ config.moderation_model = "omni-moderation-config"
322
+ end
323
+
324
+ with_stubbed_http(moderation_response) do |captured, _response|
325
+ AiLite.moderate("Use configured defaults")
326
+ payload = JSON.parse(captured[:http].last_request.body)
327
+
328
+ assert_equal "omni-moderation-config", payload["model"]
329
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
330
+ end
331
+ end
332
+
333
+ def test_moderate_accepts_text_and_image_url_keywords
334
+ client = AiLite.new(api_key: "token-abc")
335
+
336
+ with_stubbed_http(moderation_response) do |captured, _response|
337
+ client.moderate(
338
+ text: "Profile caption",
339
+ image_url: "https://example.com/image.png"
340
+ )
341
+ payload = JSON.parse(captured[:http].last_request.body)
342
+
343
+ assert_equal(
344
+ [
345
+ { "type" => "text", "text" => "Profile caption" },
346
+ {
347
+ "type" => "image_url",
348
+ "image_url" => { "url" => "https://example.com/image.png" }
349
+ }
350
+ ],
351
+ payload["input"]
352
+ )
353
+ end
354
+ end
355
+
356
+ def test_moderate_accepts_raw_openai_input_shape
357
+ client = AiLite.new(api_key: "token-abc")
358
+ input = [
359
+ { type: "text", text: "Caption" },
360
+ {
361
+ type: "image_url",
362
+ image_url: {
363
+ url: "https://example.com/image.png"
364
+ }
365
+ }
366
+ ]
367
+
368
+ with_stubbed_http(moderation_response) do |captured, _response|
369
+ client.moderate(input)
370
+ payload = JSON.parse(captured[:http].last_request.body)
371
+
372
+ assert_equal(
373
+ [
374
+ { "type" => "text", "text" => "Caption" },
375
+ {
376
+ "type" => "image_url",
377
+ "image_url" => { "url" => "https://example.com/image.png" }
378
+ }
379
+ ],
380
+ payload["input"]
381
+ )
382
+ end
383
+ end
384
+
385
+ def test_moderate_reads_image_path_as_data_url
386
+ client = AiLite.new(api_key: "token-abc")
387
+
388
+ Tempfile.create(["moderation", ".png"]) do |file|
389
+ file.binmode
390
+ file.write("fake image")
391
+ file.flush
392
+
393
+ with_stubbed_http(moderation_response) do |captured, _response|
394
+ client.moderate(image_path: file.path)
395
+ payload = JSON.parse(captured[:http].last_request.body)
396
+
397
+ assert_equal(
398
+ [
399
+ {
400
+ "type" => "image_url",
401
+ "image_url" => { "url" => "data:image/png;base64,ZmFrZSBpbWFnZQ==" }
402
+ }
403
+ ],
404
+ payload["input"]
405
+ )
406
+ end
407
+ end
408
+ end
409
+
410
+ def test_moderate_returns_all_results_for_multiple_inputs
411
+ client = AiLite.new(api_key: "token-abc")
412
+ safe_result = moderation_result(flagged: false)
413
+ flagged_result = moderation_result(flagged: true)
414
+
415
+ with_stubbed_http(moderation_response(results: [safe_result, flagged_result])) do |_captured, _response|
416
+ result = client.moderate(["Safe text", "Risky text"])
417
+
418
+ assert_equal [safe_result, flagged_result], result["content"]
419
+ assert_equal 200, result["status"]
420
+ assert_nil result["error"]
421
+ end
422
+ end
423
+
424
+ def test_moderate_local_input_errors_return_standard_envelope
425
+ client = AiLite.new(api_key: "token-abc")
426
+
427
+ result = client.moderate
428
+
429
+ assert_nil result["content"]
430
+ assert_nil result["response_id"]
431
+ assert_equal "unknown", result["status"]
432
+ assert_equal "Missing moderation input", result["error"]
433
+ assert_nil result["raw"]
434
+ end
435
+
436
+ def test_embed_sends_post_to_embeddings_with_default_payload
437
+ client = AiLite.new(api_key: "token-abc")
438
+
439
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |captured, _response|
440
+ result = client.embed("Text to vectorize")
441
+ request = captured[:http].last_request
442
+ payload = JSON.parse(request.body)
443
+
444
+ assert_equal [0.12, -0.34, 0.56], result["content"]
445
+ assert_nil result["response_id"]
446
+ assert_equal 200, result["status"]
447
+ assert_nil result["error"]
448
+ assert_nil result["raw"]
449
+ assert_equal "api.openai.com", captured[:host]
450
+ assert_equal 443, captured[:port]
451
+ assert_equal true, captured[:use_ssl]
452
+ assert_instance_of Net::HTTP::Post, request
453
+ assert_equal "/v1/embeddings", request.path
454
+ assert_equal "Bearer token-abc", request["Authorization"]
455
+ assert_equal "application/json", request["Content-Type"]
456
+ assert_equal "text-embedding-3-small", payload["model"]
457
+ assert_equal "Text to vectorize", payload["input"]
458
+ end
459
+ end
460
+
461
+ def test_embed_returns_vector_list_for_multiple_inputs
462
+ client = AiLite.new(api_key: "token-abc")
463
+ embeddings = [
464
+ [0.11, 0.22, 0.33],
465
+ [0.44, 0.55, 0.66]
466
+ ]
467
+
468
+ with_stubbed_http(embedding_response(embeddings)) do |captured, _response|
469
+ result = client.embed(["First text", "Second text"])
470
+ payload = JSON.parse(captured[:http].last_request.body)
471
+
472
+ assert_equal ["First text", "Second text"], payload["input"]
473
+ assert_equal embeddings, result["content"]
474
+ assert_equal 200, result["status"]
475
+ assert_nil result["error"]
476
+ end
477
+ end
478
+
479
+ def test_embed_includes_options_dimensions_encoding_format_and_model
480
+ client = AiLite.new(api_key: "token-abc")
481
+
482
+ with_stubbed_http(embedding_response([[0.12, -0.34]])) do |captured, _response|
483
+ client.embed(
484
+ "Short text",
485
+ model: "text-embedding-3-large",
486
+ dimensions: 2,
487
+ encoding_format: "float",
488
+ options: {
489
+ user: "user-123"
490
+ }
491
+ )
492
+ payload = JSON.parse(captured[:http].last_request.body)
493
+
494
+ assert_equal "text-embedding-3-large", payload["model"]
495
+ assert_equal "Short text", payload["input"]
496
+ assert_equal 2, payload["dimensions"]
497
+ assert_equal "float", payload["encoding_format"]
498
+ assert_equal "user-123", payload["user"]
499
+ end
500
+ end
501
+
502
+ def test_embed_uses_class_level_configured_client
503
+ AiLite.configure do |config|
504
+ config.api_key = "configured-key"
505
+ config.embedding_model = "text-embedding-config"
506
+ end
507
+
508
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |captured, _response|
509
+ AiLite.embed("Use configured defaults")
510
+ payload = JSON.parse(captured[:http].last_request.body)
511
+
512
+ assert_equal "text-embedding-config", payload["model"]
513
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
514
+ end
515
+ end
516
+
517
+ def test_embed_debug_true_returns_raw_usage
518
+ client = AiLite.new(api_key: "token-abc")
519
+
520
+ with_stubbed_http(embedding_response([[0.12, -0.34, 0.56]])) do |_captured, _response|
521
+ result = client.embed("Text to vectorize", debug: true)
522
+
523
+ assert_equal [0.12, -0.34, 0.56], result["content"]
524
+ assert_equal({ "prompt_tokens" => 4, "total_tokens" => 4 }, result["raw"]["usage"])
525
+ end
526
+ end
527
+
280
528
  def test_http_errors_return_standard_envelope
281
529
  client = AiLite.new(api_key: "token-abc")
282
530
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -369,6 +617,48 @@ class AiLiteTest < Minitest::Test
369
617
  FakeResponse.new("200", body)
370
618
  end
371
619
 
620
+ def moderation_response(results: [moderation_result])
621
+ body = JSON.generate(
622
+ "id" => "modr_test_123",
623
+ "model" => "omni-moderation-latest",
624
+ "results" => results
625
+ )
626
+ FakeResponse.new("200", body)
627
+ end
628
+
629
+ def moderation_result(flagged: false)
630
+ {
631
+ "flagged" => flagged,
632
+ "categories" => {
633
+ "hate" => false,
634
+ "violence" => flagged
635
+ },
636
+ "category_scores" => {
637
+ "hate" => 0.01,
638
+ "violence" => flagged ? 0.95 : 0.02
639
+ }
640
+ }
641
+ end
642
+
643
+ def embedding_response(embeddings)
644
+ body = JSON.generate(
645
+ "object" => "list",
646
+ "data" => embeddings.each_with_index.map do |embedding, index|
647
+ {
648
+ "object" => "embedding",
649
+ "index" => index,
650
+ "embedding" => embedding
651
+ }
652
+ end,
653
+ "model" => "text-embedding-3-small",
654
+ "usage" => {
655
+ "prompt_tokens" => 4,
656
+ "total_tokens" => 4
657
+ }
658
+ )
659
+ FakeResponse.new("200", body)
660
+ end
661
+
372
662
  def with_env(values)
373
663
  originals = {}
374
664
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.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-05-29 00:00:00.000000000 Z
11
+ date: 2026-07-07 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
14
  projects that need simple OpenAI Responses API calls.