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 +4 -4
- data/README.md +73 -4
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +108 -5
- data/test/ai_lite_test.rb +173 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fbb65bf2c01cdc7950a76cc5a273c0b495174bb9d671f7b5d32d49a79274b34
|
|
4
|
+
data.tar.gz: e8e909ff9c107f552e1f6a81772b3800bb2b1904bf9a5da50cfe6a2ca2380bd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/ai_lite/version.rb
CHANGED
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(
|
|
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.
|
|
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-
|
|
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.
|