ai-lite 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fbb65bf2c01cdc7950a76cc5a273c0b495174bb9d671f7b5d32d49a79274b34
4
- data.tar.gz: e8e909ff9c107f552e1f6a81772b3800bb2b1904bf9a5da50cfe6a2ca2380bd4
3
+ metadata.gz: cd1b2ead0a8ae48d9617b9e73460649f817fc158430e11cc7b9ac798a86846d2
4
+ data.tar.gz: f3dad03f56f3907e537dc8c72d63bf7e57e069d4b28ef666151d348b98f6b3e6
5
5
  SHA512:
6
- metadata.gz: ef2a0f01ea606b121b583134f6026c7d23388b3a58732a5a1f197275cb7178d2547ffea5040956955fd39452f944f1703833a4ba4925d0d2314ef2693372b904
7
- data.tar.gz: 272108490b47700c06f49fb817d684fa6a2dbd003e02eff35fa59f19db8fe120e41983d941c32a50c640ef6776aee00038bc5ed841bef60cdde04fee854ea0c4
6
+ metadata.gz: 6e70409efd1d697b2578ace5b878e74ef4e6214efbcbae2c295aae066e0e9ce4887cd39c95fcdde8bc73e0f6b0cfe60434dc7f1ca2611c77e649cc645b94351a
7
+ data.tar.gz: 1faba64c32c14038f18c1d50a2f73bd92a0c940d8b303b3eecb89c2193d944d3b189334c5ecde81f55e8268db01e01e562686b74f20111105f0c0fbbeea387c6
data/README.md CHANGED
@@ -18,6 +18,7 @@ It is not meant to replace the official OpenAI SDK. It is a small wrapper for pr
18
18
  ```ruby
19
19
  ai.chat("Say hello")
20
20
  ai.moderate("User submitted text")
21
+ ai.embed("Text to vectorize")
21
22
  ```
22
23
 
23
24
  ## Usage
@@ -49,6 +50,7 @@ AiLite.configure do |config|
49
50
  config.api_key = ENV["OPENAI_API_KEY"]
50
51
  config.model = "gpt-5.5"
51
52
  config.moderation_model = "omni-moderation-latest"
53
+ config.embedding_model = "text-embedding-3-small"
52
54
  config.timeout = 120
53
55
  config.max_output_tokens = 2000
54
56
  end
@@ -95,16 +97,43 @@ The OpenAI API URL is fixed to `https://api.openai.com/v1/responses`.
95
97
 
96
98
  ## Moderation
97
99
 
98
- Use `moderate` to classify text or images for potentially harmful content:
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:
99
103
 
100
104
  ```ruby
101
- result = ai.moderate("User submitted text")
105
+ comment = "User submitted comment"
106
+ result = ai.moderate(comment)
102
107
 
103
108
  if result["content"]["flagged"]
104
- # hide, block, or route to review
109
+ # hide, block, or route the comment to review
110
+ else
111
+ # publish the comment
105
112
  end
106
113
  ```
107
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
+
108
137
  `moderate` sends a `POST` request to `/v1/moderations` with:
109
138
 
110
139
  - `model`
@@ -144,6 +173,72 @@ result = ai.moderate([
144
173
  ])
145
174
  ```
146
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
+
147
242
  ## Multi-Turn Chat
148
243
 
149
244
  Responses include a `response_id` that can be passed back through `options` as `previous_response_id`:
@@ -189,22 +284,6 @@ JSON-looking model output:
189
284
  }
190
285
  ```
191
286
 
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
-
208
287
  Failure:
209
288
 
210
289
  ```ruby
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "0.2.0".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
data/lib/ai_lite.rb CHANGED
@@ -8,6 +8,7 @@ class AiLite
8
8
  API_BASE_URL = "https://api.openai.com/v1".freeze
9
9
  DEFAULT_MODEL = "gpt-5.5".freeze
10
10
  DEFAULT_MODERATION_MODEL = "omni-moderation-latest".freeze
11
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small".freeze
11
12
  DEFAULT_TIMEOUT = 120
12
13
  DEFAULT_MAX_OUTPUT_TOKENS = 2000
13
14
  IMAGE_MIME_TYPES = {
@@ -19,12 +20,13 @@ class AiLite
19
20
  }.freeze
20
21
 
21
22
  class Configuration
22
- attr_accessor :api_key, :model, :moderation_model, :timeout, :max_output_tokens
23
+ attr_accessor :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens
23
24
 
24
25
  def initialize
25
26
  @api_key = nil
26
27
  @model = DEFAULT_MODEL
27
28
  @moderation_model = DEFAULT_MODERATION_MODEL
29
+ @embedding_model = DEFAULT_EMBEDDING_MODEL
28
30
  @timeout = DEFAULT_TIMEOUT
29
31
  @max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
30
32
  end
@@ -59,19 +61,24 @@ class AiLite
59
61
  client.moderate(input, **kwargs)
60
62
  end
61
63
 
64
+ def embed(input, **kwargs)
65
+ client.embed(input, **kwargs)
66
+ end
67
+
62
68
  def reset_client!
63
69
  @client = nil
64
70
  end
65
71
  end
66
72
 
67
- attr_reader :api_key, :model, :moderation_model, :timeout, :max_output_tokens, :headers
73
+ attr_reader :api_key, :model, :moderation_model, :embedding_model, :timeout, :max_output_tokens, :headers
68
74
 
69
- def initialize(api_key: nil, model: nil, moderation_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)
70
76
  @api_key = api_key || self.class.configuration.api_key || ENV["OPENAI_API_KEY"] || ENV["OPEN_AI_TOKEN"]
71
77
  raise ArgumentError, "Missing OpenAI API key" if @api_key.to_s.strip.empty?
72
78
 
73
79
  @model = model || self.class.configuration.model
74
80
  @moderation_model = moderation_model || self.class.configuration.moderation_model
81
+ @embedding_model = embedding_model || self.class.configuration.embedding_model
75
82
  @timeout = timeout || self.class.configuration.timeout
76
83
  @max_output_tokens = max_output_tokens || self.class.configuration.max_output_tokens
77
84
  @headers = {
@@ -104,6 +111,19 @@ class AiLite
104
111
  prettify_data(status: "unknown", error: e.message, raw: nil, debug: debug)
105
112
  end
106
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
+
107
127
  private
108
128
 
109
129
  def post(payload, endpoint: response_endpoint)
@@ -131,6 +151,10 @@ class AiLite
131
151
  "#{API_BASE_URL}/moderations"
132
152
  end
133
153
 
154
+ def embedding_endpoint
155
+ "#{API_BASE_URL}/embeddings"
156
+ end
157
+
134
158
  def extract_content(response, debug: false)
135
159
  status = response.code.to_i
136
160
  parsed_response = JSON.parse(response.body)
@@ -187,6 +211,33 @@ class AiLite
187
211
  prettify_data(status: response_status(response), error: e.message, raw: nil, debug: debug)
188
212
  end
189
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
+
190
241
  def extract_output_text(raw)
191
242
  Array(raw["output"]).flat_map do |item|
192
243
  next [] unless item.is_a?(Hash) && item["type"] == "message"
@@ -253,6 +304,14 @@ class AiLite
253
304
  results.length == 1 ? results.first : results
254
305
  end
255
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
+
256
315
  def success_status?(status)
257
316
  status >= 200 && status < 300
258
317
  end
data/test/ai_lite_test.rb CHANGED
@@ -39,6 +39,7 @@ class AiLiteTest < Minitest::Test
39
39
  assert_equal "explicit-key", client.api_key
40
40
  assert_equal "gpt-test", client.model
41
41
  assert_equal "omni-moderation-latest", client.moderation_model
42
+ assert_equal "text-embedding-3-small", client.embedding_model
42
43
  assert_equal 10, client.timeout
43
44
  assert_equal 2000, client.max_output_tokens
44
45
  assert_equal "Bearer explicit-key", client.headers["Authorization"]
@@ -52,6 +53,7 @@ class AiLiteTest < Minitest::Test
52
53
  config.api_key = "configured-key"
53
54
  config.model = "gpt-config"
54
55
  config.moderation_model = "omni-moderation-test"
56
+ config.embedding_model = "text-embedding-test"
55
57
  config.timeout = 15
56
58
  config.max_output_tokens = 750
57
59
  end
@@ -61,6 +63,7 @@ class AiLiteTest < Minitest::Test
61
63
  assert_equal "configured-key", client.api_key
62
64
  assert_equal "gpt-config", client.model
63
65
  assert_equal "omni-moderation-test", client.moderation_model
66
+ assert_equal "text-embedding-test", client.embedding_model
64
67
  assert_equal 15, client.timeout
65
68
  assert_equal 750, client.max_output_tokens
66
69
  assert_same client, AiLite.client
@@ -83,6 +86,7 @@ class AiLiteTest < Minitest::Test
83
86
  config.api_key = "configured-key"
84
87
  config.model = "gpt-config"
85
88
  config.moderation_model = "omni-moderation-config"
89
+ config.embedding_model = "text-embedding-config"
86
90
  config.timeout = 15
87
91
  config.max_output_tokens = 750
88
92
  end
@@ -91,6 +95,7 @@ class AiLiteTest < Minitest::Test
91
95
  api_key: "explicit-key",
92
96
  model: "gpt-explicit",
93
97
  moderation_model: "omni-moderation-explicit",
98
+ embedding_model: "text-embedding-explicit",
94
99
  timeout: 5,
95
100
  max_output_tokens: 300
96
101
  )
@@ -98,6 +103,7 @@ class AiLiteTest < Minitest::Test
98
103
  assert_equal "explicit-key", client.api_key
99
104
  assert_equal "gpt-explicit", client.model
100
105
  assert_equal "omni-moderation-explicit", client.moderation_model
106
+ assert_equal "text-embedding-explicit", client.embedding_model
101
107
  assert_equal 5, client.timeout
102
108
  assert_equal 300, client.max_output_tokens
103
109
  end
@@ -427,6 +433,98 @@ class AiLiteTest < Minitest::Test
427
433
  assert_nil result["raw"]
428
434
  end
429
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
+
430
528
  def test_http_errors_return_standard_envelope
431
529
  client = AiLite.new(api_key: "token-abc")
432
530
  body = JSON.generate("error" => { "message" => "Invalid API key" })
@@ -542,6 +640,25 @@ class AiLiteTest < Minitest::Test
542
640
  }
543
641
  end
544
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
+
545
662
  def with_env(values)
546
663
  originals = {}
547
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.2.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-06-19 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: AI Lite is a dependency-light Ruby client for Rails apps and plain Ruby
14
14
  projects that need simple OpenAI Responses API calls.