ai-lite 0.1.0 → 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 +94 -4
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +124 -8
- data/test/ai_lite_test.rb +198 -0
- metadata +7 -7
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,15 +93,84 @@ 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
|
+
|
|
147
|
+
## Multi-Turn Chat
|
|
148
|
+
|
|
149
|
+
Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
first = ai.chat("Tell me a short joke.")
|
|
153
|
+
|
|
154
|
+
follow_up = ai.chat(
|
|
155
|
+
"Explain why that is funny.",
|
|
156
|
+
options: {
|
|
157
|
+
previous_response_id: first["response_id"]
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
puts follow_up["content"]
|
|
162
|
+
```
|
|
163
|
+
|
|
94
164
|
## Return Shape
|
|
95
165
|
|
|
96
|
-
|
|
166
|
+
Methods return a hash envelope.
|
|
97
167
|
|
|
98
168
|
Text output:
|
|
99
169
|
|
|
100
170
|
```ruby
|
|
101
171
|
{
|
|
102
172
|
"content" => "Hello!",
|
|
173
|
+
"response_id" => "resp_...",
|
|
103
174
|
"status" => 200,
|
|
104
175
|
"error" => nil,
|
|
105
176
|
"raw" => nil
|
|
@@ -111,6 +182,23 @@ JSON-looking model output:
|
|
|
111
182
|
```ruby
|
|
112
183
|
{
|
|
113
184
|
"content" => { "valid" => true },
|
|
185
|
+
"response_id" => "resp_...",
|
|
186
|
+
"status" => 200,
|
|
187
|
+
"error" => nil,
|
|
188
|
+
"raw" => nil
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Moderation output:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
{
|
|
196
|
+
"content" => {
|
|
197
|
+
"flagged" => false,
|
|
198
|
+
"categories" => { ... },
|
|
199
|
+
"category_scores" => { ... }
|
|
200
|
+
},
|
|
201
|
+
"response_id" => "modr_...",
|
|
114
202
|
"status" => 200,
|
|
115
203
|
"error" => nil,
|
|
116
204
|
"raw" => nil
|
|
@@ -122,6 +210,7 @@ Failure:
|
|
|
122
210
|
```ruby
|
|
123
211
|
{
|
|
124
212
|
"content" => nil,
|
|
213
|
+
"response_id" => nil,
|
|
125
214
|
"status" => 401,
|
|
126
215
|
"error" => "Invalid API key",
|
|
127
216
|
"raw" => nil
|
|
@@ -135,6 +224,7 @@ result = ai.chat("Say hello", debug: true)
|
|
|
135
224
|
|
|
136
225
|
{
|
|
137
226
|
"content" => "Hello!",
|
|
227
|
+
"response_id" => "resp_...",
|
|
138
228
|
"status" => 200,
|
|
139
229
|
"error" => nil,
|
|
140
230
|
"raw" => { ... }
|
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,17 +127,60 @@ 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)
|
|
107
137
|
|
|
108
138
|
unless success_status?(status)
|
|
109
|
-
return prettify_data(
|
|
139
|
+
return prettify_data(
|
|
140
|
+
status: status,
|
|
141
|
+
error: error_message(parsed_response),
|
|
142
|
+
response_id: parsed_response["id"],
|
|
143
|
+
raw: parsed_response,
|
|
144
|
+
debug: debug
|
|
145
|
+
)
|
|
110
146
|
end
|
|
111
147
|
|
|
112
148
|
raw_content = extract_output_text(parsed_response)
|
|
113
149
|
content = parse_content(raw_content)
|
|
114
|
-
prettify_data(
|
|
150
|
+
prettify_data(
|
|
151
|
+
status: status,
|
|
152
|
+
content: content,
|
|
153
|
+
response_id: parsed_response["id"],
|
|
154
|
+
raw: parsed_response,
|
|
155
|
+
debug: debug
|
|
156
|
+
)
|
|
157
|
+
rescue JSON::ParserError => e
|
|
158
|
+
prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
|
|
159
|
+
rescue => e
|
|
160
|
+
prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
|
|
161
|
+
end
|
|
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
|
+
)
|
|
115
184
|
rescue JSON::ParserError => e
|
|
116
185
|
prettify_data(status: response_status(response), error: e.message, raw: response&.body, debug: debug)
|
|
117
186
|
rescue => e
|
|
@@ -138,6 +207,52 @@ class AiLite
|
|
|
138
207
|
raw_text
|
|
139
208
|
end
|
|
140
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
|
+
|
|
141
256
|
def success_status?(status)
|
|
142
257
|
status >= 200 && status < 300
|
|
143
258
|
end
|
|
@@ -154,9 +269,10 @@ class AiLite
|
|
|
154
269
|
response&.code&.to_i || "unknown"
|
|
155
270
|
end
|
|
156
271
|
|
|
157
|
-
def prettify_data(status:, content: nil, error: nil, raw:, debug: false)
|
|
272
|
+
def prettify_data(status:, content: nil, error: nil, response_id: nil, raw:, debug: false)
|
|
158
273
|
{
|
|
159
274
|
"content" => content,
|
|
275
|
+
"response_id" => response_id,
|
|
160
276
|
"status" => status,
|
|
161
277
|
"error" => error,
|
|
162
278
|
"raw" => debug ? raw : nil
|
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
|
|
@@ -130,6 +137,7 @@ class AiLiteTest < Minitest::Test
|
|
|
130
137
|
payload = JSON.parse(request.body)
|
|
131
138
|
|
|
132
139
|
assert_equal "Hello!", result["content"]
|
|
140
|
+
assert_equal "resp_test_123", result["response_id"]
|
|
133
141
|
assert_equal 200, result["status"]
|
|
134
142
|
assert_nil result["error"]
|
|
135
143
|
assert_nil result["raw"]
|
|
@@ -196,9 +204,28 @@ class AiLiteTest < Minitest::Test
|
|
|
196
204
|
end
|
|
197
205
|
end
|
|
198
206
|
|
|
207
|
+
def test_chat_supports_previous_response_id_through_options
|
|
208
|
+
client = AiLite.new(api_key: "token-abc")
|
|
209
|
+
|
|
210
|
+
with_stubbed_http(success_response("Follow-up")) do |captured, _response|
|
|
211
|
+
result = client.chat(
|
|
212
|
+
"Explain why that is funny",
|
|
213
|
+
options: {
|
|
214
|
+
previous_response_id: "resp_previous_123"
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
payload = JSON.parse(captured[:http].last_request.body)
|
|
218
|
+
|
|
219
|
+
assert_equal "resp_previous_123", payload["previous_response_id"]
|
|
220
|
+
assert_equal "Follow-up", result["content"]
|
|
221
|
+
assert_equal "resp_test_123", result["response_id"]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
199
225
|
def test_extracts_text_from_nested_output_text_items
|
|
200
226
|
client = AiLite.new(api_key: "token-abc")
|
|
201
227
|
body = JSON.generate(
|
|
228
|
+
"id" => "resp_nested_123",
|
|
202
229
|
"output" => [
|
|
203
230
|
{ "type" => "reasoning", "content" => [] },
|
|
204
231
|
{
|
|
@@ -215,6 +242,7 @@ class AiLiteTest < Minitest::Test
|
|
|
215
242
|
result = client.chat("Say hello")
|
|
216
243
|
|
|
217
244
|
assert_equal "Hello world", result["content"]
|
|
245
|
+
assert_equal "resp_nested_123", result["response_id"]
|
|
218
246
|
assert_equal 200, result["status"]
|
|
219
247
|
assert_nil result["error"]
|
|
220
248
|
end
|
|
@@ -256,6 +284,149 @@ class AiLiteTest < Minitest::Test
|
|
|
256
284
|
end
|
|
257
285
|
end
|
|
258
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
|
+
|
|
259
430
|
def test_http_errors_return_standard_envelope
|
|
260
431
|
client = AiLite.new(api_key: "token-abc")
|
|
261
432
|
body = JSON.generate("error" => { "message" => "Invalid API key" })
|
|
@@ -264,6 +435,7 @@ class AiLiteTest < Minitest::Test
|
|
|
264
435
|
result = client.chat("Say hello")
|
|
265
436
|
|
|
266
437
|
assert_nil result["content"]
|
|
438
|
+
assert_nil result["response_id"]
|
|
267
439
|
assert_equal 401, result["status"]
|
|
268
440
|
assert_equal "Invalid API key", result["error"]
|
|
269
441
|
assert_nil result["raw"]
|
|
@@ -291,6 +463,7 @@ class AiLiteTest < Minitest::Test
|
|
|
291
463
|
result = client.chat("Say hello")
|
|
292
464
|
|
|
293
465
|
assert_nil result["content"]
|
|
466
|
+
assert_nil result["response_id"]
|
|
294
467
|
assert_equal 200, result["status"]
|
|
295
468
|
assert_match(/unexpected token|unexpected character|parse/i, result["error"])
|
|
296
469
|
assert_nil result["raw"]
|
|
@@ -321,6 +494,7 @@ class AiLiteTest < Minitest::Test
|
|
|
321
494
|
result = client.chat("Say hello")
|
|
322
495
|
|
|
323
496
|
assert_nil result["content"]
|
|
497
|
+
assert_nil result["response_id"]
|
|
324
498
|
assert_equal "unknown", result["status"]
|
|
325
499
|
assert_equal "connection failed", result["error"]
|
|
326
500
|
assert_nil result["raw"]
|
|
@@ -332,6 +506,7 @@ class AiLiteTest < Minitest::Test
|
|
|
332
506
|
|
|
333
507
|
def success_response(content)
|
|
334
508
|
body = JSON.generate(
|
|
509
|
+
"id" => "resp_test_123",
|
|
335
510
|
"output" => [
|
|
336
511
|
{
|
|
337
512
|
"type" => "message",
|
|
@@ -344,6 +519,29 @@ class AiLiteTest < Minitest::Test
|
|
|
344
519
|
FakeResponse.new("200", body)
|
|
345
520
|
end
|
|
346
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
|
+
|
|
347
545
|
def with_env(values)
|
|
348
546
|
originals = {}
|
|
349
547
|
|
metadata
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
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
|
-
autorequire:
|
|
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.
|
|
15
|
-
email:
|
|
15
|
+
email:
|
|
16
16
|
executables: []
|
|
17
17
|
extensions: []
|
|
18
18
|
extra_rdoc_files: []
|
|
@@ -27,7 +27,7 @@ homepage: https://github.com/wbasmayor/ai-lite
|
|
|
27
27
|
licenses:
|
|
28
28
|
- MIT
|
|
29
29
|
metadata: {}
|
|
30
|
-
post_install_message:
|
|
30
|
+
post_install_message:
|
|
31
31
|
rdoc_options: []
|
|
32
32
|
require_paths:
|
|
33
33
|
- lib
|
|
@@ -42,8 +42,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
42
42
|
- !ruby/object:Gem::Version
|
|
43
43
|
version: '0'
|
|
44
44
|
requirements: []
|
|
45
|
-
rubygems_version: 3.
|
|
46
|
-
signing_key:
|
|
45
|
+
rubygems_version: 3.0.3.1
|
|
46
|
+
signing_key:
|
|
47
47
|
specification_version: 4
|
|
48
48
|
summary: Minimal Ruby client for simple AI chat calls through the OpenAI Responses
|
|
49
49
|
API.
|