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 +4 -4
- data/README.md +152 -4
- data/lib/ai_lite/version.rb +1 -1
- data/lib/ai_lite.rb +167 -5
- data/test/ai_lite_test.rb +290 -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: cd1b2ead0a8ae48d9617b9e73460649f817fc158430e11cc7b9ac798a86846d2
|
|
4
|
+
data.tar.gz: f3dad03f56f3907e537dc8c72d63bf7e57e069d4b28ef666151d348b98f6b3e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 `
|
|
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")
|
|
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
|
-
|
|
261
|
+
Methods return a hash envelope.
|
|
114
262
|
|
|
115
263
|
Text output:
|
|
116
264
|
|
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,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(
|
|
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.
|
|
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-
|
|
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.
|