ai-lite 0.1.1 → 0.2.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: 2fbb65bf2c01cdc7950a76cc5a273c0b495174bb9d671f7b5d32d49a79274b34
4
+ data.tar.gz: e8e909ff9c107f552e1f6a81772b3800bb2b1904bf9a5da50cfe6a2ca2380bd4
5
5
  SHA512:
6
- metadata.gz: eae14b861938c6a351a1b0430eb8ec03569a41fec7896af012addfb2fb5e4f60d6a28345c2cde6c1b82270ccd40cda21da9db470fa7694a2368930ab3e1f1072
7
- data.tar.gz: a31f91cf5db94e669f992b03added0312d287aeb991b327adc3f7703d8cc582e460d26d3e7e2c4692a3a29693f2de6cf9998b4e59be593e7a64569b6fb66758d
6
+ metadata.gz: ef2a0f01ea606b121b583134f6026c7d23388b3a58732a5a1f197275cb7178d2547ffea5040956955fd39452f944f1703833a4ba4925d0d2314ef2693372b904
7
+ data.tar.gz: 272108490b47700c06f49fb817d684fa6a2dbd003e02eff35fa59f19db8fe120e41983d941c32a50c640ef6776aee00038bc5ed841bef60cdde04fee854ea0c4
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,13 @@ 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")
20
21
  ```
21
22
 
22
23
  ## Usage
@@ -47,6 +48,7 @@ In Rails, configure the default client from an initializer:
47
48
  AiLite.configure do |config|
48
49
  config.api_key = ENV["OPENAI_API_KEY"]
49
50
  config.model = "gpt-5.5"
51
+ config.moderation_model = "omni-moderation-latest"
50
52
  config.timeout = 120
51
53
  config.max_output_tokens = 2000
52
54
  end
@@ -91,6 +93,57 @@ The default model is `gpt-5.5`.
91
93
 
92
94
  The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
93
95
 
96
+ ## Moderation
97
+
98
+ Use `moderate` to classify text or images for potentially harmful content:
99
+
100
+ ```ruby
101
+ result = ai.moderate("User submitted text")
102
+
103
+ if result["content"]["flagged"]
104
+ # hide, block, or route to review
105
+ end
106
+ ```
107
+
108
+ `moderate` sends a `POST` request to `/v1/moderations` with:
109
+
110
+ - `model`
111
+ - `input`
112
+ - optional extra `options`
113
+
114
+ The default moderation model is `omni-moderation-latest`.
115
+
116
+ You can pass an image URL:
117
+
118
+ ```ruby
119
+ result = ai.moderate(
120
+ text: "Profile caption",
121
+ image_url: "https://example.com/image.png"
122
+ )
123
+ ```
124
+
125
+ Or a local image path:
126
+
127
+ ```ruby
128
+ result = ai.moderate(image_path: "tmp/upload.png")
129
+ ```
130
+
131
+ 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`.
132
+
133
+ You can also pass OpenAI's raw moderation input shape directly:
134
+
135
+ ```ruby
136
+ result = ai.moderate([
137
+ { type: "text", text: "Caption" },
138
+ {
139
+ type: "image_url",
140
+ image_url: {
141
+ url: "https://example.com/image.png"
142
+ }
143
+ }
144
+ ])
145
+ ```
146
+
94
147
  ## Multi-Turn Chat
95
148
 
96
149
  Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
@@ -110,7 +163,7 @@ puts follow_up["content"]
110
163
 
111
164
  ## Return Shape
112
165
 
113
- `chat` always returns a hash envelope.
166
+ Methods return a hash envelope.
114
167
 
115
168
  Text output:
116
169
 
@@ -136,6 +189,22 @@ JSON-looking model output:
136
189
  }
137
190
  ```
138
191
 
192
+ Moderation output:
193
+
194
+ ```ruby
195
+ {
196
+ "content" => {
197
+ "flagged" => false,
198
+ "categories" => { ... },
199
+ "category_scores" => { ... }
200
+ },
201
+ "response_id" => "modr_...",
202
+ "status" => 200,
203
+ "error" => nil,
204
+ "raw" => nil
205
+ }
206
+ ```
207
+
139
208
  Failure:
140
209
 
141
210
  ```ruby
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.1.1".freeze
2
+ VERSION = "0.2.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,24 @@ 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
9
11
  DEFAULT_TIMEOUT = 120
10
12
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
13
+ IMAGE_MIME_TYPES = {
14
+ ".gif" => "image/gif",
15
+ ".jpeg" => "image/jpeg",
16
+ ".jpg" => "image/jpeg",
17
+ ".png" => "image/png",
18
+ ".webp" => "image/webp"
19
+ }.freeze
11
20
 
12
21
  class Configuration
13
- attr_accessor :api_key, :model, :timeout, :max_output_tokens
22
+ attr_accessor :api_key, :model, :moderation_model, :timeout, :max_output_tokens
14
23
 
15
24
  def initialize
16
25
  @api_key = nil
17
26
  @model = DEFAULT_MODEL
27
+ @moderation_model = DEFAULT_MODERATION_MODEL
18
28
  @timeout = DEFAULT_TIMEOUT
19
29
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
20
30
  end
@@ -45,18 +55,23 @@ class AiLite
45
55
  client.chat(message, **kwargs)
46
56
  end
47
57
 
58
+ def moderate(input = nil, **kwargs)
59
+ client.moderate(input, **kwargs)
60
+ end
61
+
48
62
  def reset_client!
49
63
  @client = nil
50
64
  end
51
65
  end
52
66
 
53
- attr_reader :api_key, :model, :timeout, :max_output_tokens, :headers
67
+ attr_reader :api_key, :model, :moderation_model, :timeout, :max_output_tokens, :headers
54
68
 
55
- def initialize(api_key: nil, model: nil, timeout: nil, max_output_tokens: nil)
69
+ def initialize(api_key: nil, model: nil, moderation_model: nil, timeout: nil, max_output_tokens: nil)
56
70
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
57
71
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
58
72
 
59
73
  @model = model || self.class.configuration.model
74
+ @moderation_model = moderation_model || self.class.configuration.moderation_model
60
75
  @timeout = timeout || self.class.configuration.timeout
61
76
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
62
77
  @headers = {
@@ -78,10 +93,21 @@ class AiLite
78
93
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
79
94
  end
80
95
 
96
+ def moderate(input = nil, model: nil, text: nil, image_url: nil, image_path: nil, debug: false, options: {})
97
+ payload = options.merge(
98
+ model: model || moderation_model,
99
+ input: moderation_input(input, text: text, image_url: image_url, image_path: image_path)
100
+ )
101
+
102
+ extract_moderation(post(payload, endpoint: moderation_endpoint), debug: debug)
103
+ rescue => e
104
+ prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
105
+ end
106
+
81
107
  private
82
108
 
83
- def post(payload)
84
- uri = URI.parse(response_endpoint)
109
+ def post(payload, endpoint: response_endpoint)
110
+ uri = URI.parse(endpoint)
85
111
  request = Net::HTTP::Post.new(uri)
86
112
 
87
113
  headers.each do |key, value|
@@ -101,6 +127,10 @@ class AiLite
101
127
  "#{API_BASE_URL}/responses"
102
128
  end
103
129
 
130
+ def moderation_endpoint
131
+ "#{API_BASE_URL}/moderations"
132
+ end
133
+
104
134
  def extract_content(response, debug: false)
105
135
  status = response.code.to_i
106
136
  parsed_response = JSON.parse(response.body)
@@ -130,6 +160,33 @@ class AiLite
130
160
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
131
161
  end
132
162
 
163
+ def extract_moderation(response, debug: false)
164
+ status = response.code.to_i
165
+ parsed_response = JSON.parse(response.body)
166
+
167
+ unless success_status?(status)
168
+ return prettify_data(
169
+ status: status,
170
+ error: error_message(parsed_response),
171
+ response_id: parsed_response["id"],
172
+ raw: parsed_response,
173
+ debug: debug
174
+ )
175
+ end
176
+
177
+ prettify_data(
178
+ status: status,
179
+ content: moderation_content(parsed_response),
180
+ response_id: parsed_response["id"],
181
+ raw: parsed_response,
182
+ debug: debug
183
+ )
184
+ rescue JSON::ParserError => e
185
+ prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
186
+ rescue => e
187
+ prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
188
+ end
189
+
133
190
  def extract_output_text(raw)
134
191
  Array(raw["output"]).flat_map do |item|
135
192
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -150,6 +207,52 @@ class AiLite
150
207
  raw_text
151
208
  end
152
209
 
210
+ def moderation_input(input, text:, image_url:, image_path:)
211
+ unless text || image_url || image_path
212
+ raise ArgumentError, "Missing moderation input" if input.nil?
213
+
214
+ return input
215
+ end
216
+
217
+ items = []
218
+ items.concat(Array(input).map { |value| moderation_input_item(value) }) unless input.nil?
219
+ items << { type: "text", text: text } if text
220
+ items << { type: "image_url", image_url: { url: image_url } } if image_url
221
+ items << { type: "image_url", image_url: { url: image_data_url(image_path) } } if image_path
222
+ raise ArgumentError, "Missing moderation input" if items.empty?
223
+
224
+ items
225
+ end
226
+
227
+ def moderation_input_item(value)
228
+ case value
229
+ when String
230
+ { type: "text", text: value }
231
+ when Hash
232
+ value
233
+ else
234
+ raise ArgumentError, "Unsupported moderation input item: #{value.class}"
235
+ end
236
+ end
237
+
238
+ def image_data_url(path)
239
+ mime_type = image_mime_type(path)
240
+ "data:#{mime_type};base64,#{Base64.strict_encode64(File.binread(path))}"
241
+ end
242
+
243
+ def image_mime_type(path)
244
+ IMAGE_MIME_TYPES.fetch(File.extname(path).downcase) do
245
+ raise ArgumentError, "Unsupported image type for moderation: #{File.extname(path)}"
246
+ end
247
+ end
248
+
249
+ def moderation_content(raw)
250
+ results = raw["results"]
251
+ return nil unless results.is_a?(Array)
252
+
253
+ results.length == 1 ? results.first : results
254
+ end
255
+
153
256
  def success_status?(status)
154
257
  status >= 200 && status < 300
155
258
  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,7 @@ 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
40
42
  assert_equal 10, client.timeout
41
43
  assert_equal 2000, client.max_output_tokens
42
44
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -49,6 +51,7 @@ class AiLiteTest < Minitest::Test
49
51
  AiLite.configure do |config|
50
52
  config.api_key = "configured-key"
51
53
  config.model = "gpt-config"
54
+ config.moderation_model = "omni-moderation-test"
52
55
  config.timeout = 15
53
56
  config.max_output_tokens = 750
54
57
  end
@@ -57,6 +60,7 @@ class AiLiteTest < Minitest::Test
57
60
 
58
61
  assert_equal "configured-key", client.api_key
59
62
  assert_equal "gpt-config", client.model
63
+ assert_equal "omni-moderation-test", client.moderation_model
60
64
  assert_equal 15, client.timeout
61
65
  assert_equal 750, client.max_output_tokens
62
66
  assert_same client, AiLite.client
@@ -78,6 +82,7 @@ class AiLiteTest < Minitest::Test
78
82
  AiLite.configure do |config|
79
83
  config.api_key = "configured-key"
80
84
  config.model = "gpt-config"
85
+ config.moderation_model = "omni-moderation-config"
81
86
  config.timeout = 15
82
87
  config.max_output_tokens = 750
83
88
  end
@@ -85,12 +90,14 @@ class AiLiteTest < Minitest::Test
85
90
  client = AiLite.new(
86
91
  api_key: "explicit-key",
87
92
  model: "gpt-explicit",
93
+ moderation_model: "omni-moderation-explicit",
88
94
  timeout: 5,
89
95
  max_output_tokens: 300
90
96
  )
91
97
 
92
98
  assert_equal "explicit-key", client.api_key
93
99
  assert_equal "gpt-explicit", client.model
100
+ assert_equal "omni-moderation-explicit", client.moderation_model
94
101
  assert_equal 5, client.timeout
95
102
  assert_equal 300, client.max_output_tokens
96
103
  end
@@ -277,6 +284,149 @@ class AiLiteTest < Minitest::Test
277
284
  end
278
285
  end
279
286
 
287
+ def test_moderate_sends_post_to_moderations_with_default_payload
288
+ client = AiLite.new(api_key: "token-abc")
289
+
290
+ with_stubbed_http(moderation_response) do |captured, _response|
291
+ result = client.moderate("Some user submitted text")
292
+ request = captured[:http].last_request
293
+ payload = JSON.parse(request.body)
294
+
295
+ assert_equal false, result["content"]["flagged"]
296
+ assert_equal "modr_test_123", result["response_id"]
297
+ assert_equal 200, result["status"]
298
+ assert_nil result["error"]
299
+ assert_nil result["raw"]
300
+ assert_equal "api.openai.com", captured[:host]
301
+ assert_equal 443, captured[:port]
302
+ assert_equal true, captured[:use_ssl]
303
+ assert_instance_of Net::HTTP::Post, request
304
+ assert_equal "/v1/moderations", request.path
305
+ assert_equal "Bearer token-abc", request["Authorization"]
306
+ assert_equal "application/json", request["Content-Type"]
307
+ assert_equal "omni-moderation-latest", payload["model"]
308
+ assert_equal "Some user submitted text", payload["input"]
309
+ end
310
+ end
311
+
312
+ def test_moderate_uses_class_level_configured_client
313
+ AiLite.configure do |config|
314
+ config.api_key = "configured-key"
315
+ config.moderation_model = "omni-moderation-config"
316
+ end
317
+
318
+ with_stubbed_http(moderation_response) do |captured, _response|
319
+ AiLite.moderate("Use configured defaults")
320
+ payload = JSON.parse(captured[:http].last_request.body)
321
+
322
+ assert_equal "omni-moderation-config", payload["model"]
323
+ assert_equal "Bearer configured-key", captured[:http].last_request["Authorization"]
324
+ end
325
+ end
326
+
327
+ def test_moderate_accepts_text_and_image_url_keywords
328
+ client = AiLite.new(api_key: "token-abc")
329
+
330
+ with_stubbed_http(moderation_response) do |captured, _response|
331
+ client.moderate(
332
+ text: "Profile caption",
333
+ image_url: "https://example.com/image.png"
334
+ )
335
+ payload = JSON.parse(captured[:http].last_request.body)
336
+
337
+ assert_equal(
338
+ [
339
+ { "type" => "text", "text" => "Profile caption" },
340
+ {
341
+ "type" => "image_url",
342
+ "image_url" => { "url" => "https://example.com/image.png" }
343
+ }
344
+ ],
345
+ payload["input"]
346
+ )
347
+ end
348
+ end
349
+
350
+ def test_moderate_accepts_raw_openai_input_shape
351
+ client = AiLite.new(api_key: "token-abc")
352
+ input = [
353
+ { type: "text", text: "Caption" },
354
+ {
355
+ type: "image_url",
356
+ image_url: {
357
+ url: "https://example.com/image.png"
358
+ }
359
+ }
360
+ ]
361
+
362
+ with_stubbed_http(moderation_response) do |captured, _response|
363
+ client.moderate(input)
364
+ payload = JSON.parse(captured[:http].last_request.body)
365
+
366
+ assert_equal(
367
+ [
368
+ { "type" => "text", "text" => "Caption" },
369
+ {
370
+ "type" => "image_url",
371
+ "image_url" => { "url" => "https://example.com/image.png" }
372
+ }
373
+ ],
374
+ payload["input"]
375
+ )
376
+ end
377
+ end
378
+
379
+ def test_moderate_reads_image_path_as_data_url
380
+ client = AiLite.new(api_key: "token-abc")
381
+
382
+ Tempfile.create(["moderation", ".png"]) do |file|
383
+ file.binmode
384
+ file.write("fake image")
385
+ file.flush
386
+
387
+ with_stubbed_http(moderation_response) do |captured, _response|
388
+ client.moderate(image_path: file.path)
389
+ payload = JSON.parse(captured[:http].last_request.body)
390
+
391
+ assert_equal(
392
+ [
393
+ {
394
+ "type" => "image_url",
395
+ "image_url" => { "url" => "data:image/png;base64,ZmFrZSBpbWFnZQ==" }
396
+ }
397
+ ],
398
+ payload["input"]
399
+ )
400
+ end
401
+ end
402
+ end
403
+
404
+ def test_moderate_returns_all_results_for_multiple_inputs
405
+ client = AiLite.new(api_key: "token-abc")
406
+ safe_result = moderation_result(flagged: false)
407
+ flagged_result = moderation_result(flagged: true)
408
+
409
+ with_stubbed_http(moderation_response(results: [safe_result, flagged_result])) do |_captured, _response|
410
+ result = client.moderate(["Safe text", "Risky text"])
411
+
412
+ assert_equal [safe_result, flagged_result], result["content"]
413
+ assert_equal 200, result["status"]
414
+ assert_nil result["error"]
415
+ end
416
+ end
417
+
418
+ def test_moderate_local_input_errors_return_standard_envelope
419
+ client = AiLite.new(api_key: "token-abc")
420
+
421
+ result = client.moderate
422
+
423
+ assert_nil result["content"]
424
+ assert_nil result["response_id"]
425
+ assert_equal "unknown", result["status"]
426
+ assert_equal "Missing moderation input", result["error"]
427
+ assert_nil result["raw"]
428
+ end
429
+
280
430
  def test_http_errors_return_standard_envelope
281
431
  client = AiLite.new(api_key: "token-abc")
282
432
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -369,6 +519,29 @@ class AiLiteTest < Minitest::Test
369
519
  FakeResponse.new("200", body)
370
520
  end
371
521
 
522
+ def moderation_response(results: [moderation_result])
523
+ body = JSON.generate(
524
+ "id" => "modr_test_123",
525
+ "model" => "omni-moderation-latest",
526
+ "results" => results
527
+ )
528
+ FakeResponse.new("200", body)
529
+ end
530
+
531
+ def moderation_result(flagged: false)
532
+ {
533
+ "flagged" => flagged,
534
+ "categories" => {
535
+ "hate" => false,
536
+ "violence" => flagged
537
+ },
538
+ "category_scores" => {
539
+ "hate" => 0.01,
540
+ "violence" => flagged ? 0.95 : 0.02
541
+ }
542
+ }
543
+ end
544
+
372
545
  def with_env(values)
373
546
  originals = {}
374
547
 
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.2.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-06-19 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.