ai-lite 1.0.0 → 1.1.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/CHANGELOG.md +9 -1
- data/README.md +93 -16
- data/lib/ai_lite/gemini/configuration.rb +5 -1
- data/lib/ai_lite/gemini/embeddings.rb +58 -0
- data/lib/ai_lite/gemini/images.rb +56 -0
- data/lib/ai_lite/gemini/models.rb +3 -1
- data/lib/ai_lite/gemini.rb +20 -3
- data/lib/ai_lite/version.rb +1 -1
- data/test/gemini_embeddings_test.rb +122 -0
- data/test/gemini_images_test.rb +154 -0
- data/test/gemini_models_test.rb +3 -2
- data/test/gemini_test.rb +1 -1
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6b2ed589de27af46e6ce7babfb4a1ec32808ecfae567f29ed8db35bfd129f26
|
|
4
|
+
data.tar.gz: ddee8996d702a77709b30bd9bb360994abfbc3e4875386db04b99241541a413d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9591556daadccbb5107f8b52f6d5ca825232003336a87db144e41971ec9921282a38ea884928d0d1f5ee8ea4d84fb8b67698ddcfb1894f678ab2bc615a9dcdb8
|
|
7
|
+
data.tar.gz: 24aa31e8ddaa48bf6876bc7e4375c212d46994d37709a32bb52c797b3618f0dc770fe831919039781c2f9529b40ff0c85b12c04dea4a7894c61fa4e8515fa282
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Add Gemini image generation with base64 results, optional file output, native
|
|
6
|
+
options, image model configuration, and model checks.
|
|
7
|
+
|
|
8
|
+
- Add Gemini text embeddings for single and multiple inputs, with dimensions,
|
|
9
|
+
embedding model configuration, model checks, and the standard result envelope.
|
|
10
|
+
|
|
3
11
|
## 1.0.0 (unreleased)
|
|
4
12
|
|
|
5
13
|
- Introduce independent `AiLite::OpenAI` and `AiLite::Gemini` providers.
|
|
@@ -14,5 +22,5 @@
|
|
|
14
22
|
- Treat failed/incomplete OpenAI generations as errors even when HTTP status is 200.
|
|
15
23
|
- Handle fragmented UTF-8 and mixed SSE line endings in both stream parsers.
|
|
16
24
|
|
|
17
|
-
Gemini
|
|
25
|
+
Gemini image generation, speech, file transcription, additional
|
|
18
26
|
providers, and async convenience methods are deferred beyond v1.
|
data/README.md
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
# AI Lite
|
|
2
2
|
|
|
3
|
-
AI Lite is a pure Ruby
|
|
4
|
-
|
|
3
|
+
AI Lite is a lightweight, pure Ruby client for OpenAI and Gemini. Both providers
|
|
4
|
+
support chat, streaming, embeddings, image generation, and model availability
|
|
5
|
+
checks through simple interfaces and a consistent result format.
|
|
5
6
|
|
|
6
|
-
It is built for Rails apps and plain Ruby projects
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
It is built for Rails apps and plain Ruby projects that need AI features with
|
|
8
|
+
minimal dependencies. It is especially useful in legacy Rails apps where Ruby,
|
|
9
|
+
Rails, or HTTP-client dependency constraints make larger SDKs difficult to add.
|
|
9
10
|
|
|
10
11
|
This gem is intentionally small:
|
|
11
12
|
|
|
12
13
|
- No Rails dependency
|
|
13
|
-
- No
|
|
14
|
+
- No provider SDK dependency
|
|
14
15
|
- No Faraday, HTTParty, ActiveSupport, or connection pool dependency
|
|
15
16
|
- Uses only Ruby stdlib: `Net::HTTP`, `URI`, `JSON`, `Base64`, and `SecureRandom`
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Choose a provider and call the methods your app needs:
|
|
18
19
|
|
|
19
20
|
```ruby
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
require "ai_lite"
|
|
22
|
+
|
|
23
|
+
openai = AiLite::OpenAI.new # Reads OPENAI_API_KEY
|
|
24
|
+
gemini = AiLite::Gemini.new # Reads GEMINI_API_KEY
|
|
25
|
+
|
|
26
|
+
openai.chat("Say hello")
|
|
27
|
+
gemini.chat("Say hello")
|
|
28
|
+
|
|
29
|
+
openai.embed("Text to vectorize")
|
|
30
|
+
gemini.embed("Text to vectorize")
|
|
31
|
+
|
|
32
|
+
openai.image("A simple app icon")
|
|
33
|
+
gemini.image("A simple app icon")
|
|
26
34
|
```
|
|
27
35
|
|
|
36
|
+
Each provider has independent configuration and native API options. OpenAI also
|
|
37
|
+
supports moderation, speech, and file transcription; Gemini supports structured
|
|
38
|
+
media input and a YouTube helper. The table below lists the available methods.
|
|
39
|
+
|
|
28
40
|
## Provider support
|
|
29
41
|
|
|
30
42
|
| Method | OpenAI | Gemini |
|
|
@@ -32,7 +44,8 @@ ai.transcribe("tmp/meeting.mp3")
|
|
|
32
44
|
| `chat`, `chat_stream` | Yes | Yes |
|
|
33
45
|
| Four model helpers | Yes | Yes |
|
|
34
46
|
| `youtube` | No | Yes |
|
|
35
|
-
| `
|
|
47
|
+
| `embed`, `image` | Yes | Yes |
|
|
48
|
+
| `moderate`, `speak`, `transcribe` | Yes | Not implemented |
|
|
36
49
|
|
|
37
50
|
Unsupported provider methods are absent and raise Ruby's `NoMethodError` if called.
|
|
38
51
|
Configure providers during application startup; do not mutate shared configuration
|
|
@@ -58,6 +71,8 @@ does not load `.env` automatically.
|
|
|
58
71
|
AiLite::Gemini.configure do |config|
|
|
59
72
|
config.api_key = ENV["GEMINI_API_KEY"]
|
|
60
73
|
config.model = "gemini-3.8-flash"
|
|
74
|
+
config.embedding_model = "gemini-embedding-001"
|
|
75
|
+
config.image_model = "gemini-nano-banana-2.1"
|
|
61
76
|
config.timeout = 120
|
|
62
77
|
config.max_output_tokens = 2000
|
|
63
78
|
end
|
|
@@ -117,10 +132,72 @@ return an error rather than pretending the answer is complete; no tools are
|
|
|
117
132
|
executed automatically. Input/API/transport errors return an error envelope;
|
|
118
133
|
constructing a client without a key raises `ArgumentError`.
|
|
119
134
|
|
|
120
|
-
Gemini
|
|
135
|
+
Gemini speech and file transcription methods are not implemented yet. Use `chat_stream` for streaming; `chat` rejects
|
|
121
136
|
`stream: true`. Both methods reject `background: true` before sending a request.
|
|
122
137
|
Legacy `AiLite` entry points still use OpenAI.
|
|
123
138
|
|
|
139
|
+
### Gemini embeddings
|
|
140
|
+
|
|
141
|
+
`embed` mirrors OpenAI's text input and return shape: a string returns one numeric
|
|
142
|
+
vector in `content`; an array returns a list of vectors in input order, including
|
|
143
|
+
an array containing just one string.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
gemini = AiLite::Gemini.new
|
|
147
|
+
result = gemini.embed("Text to vectorize")
|
|
148
|
+
result["content"] # [0.12, -0.34, ...]
|
|
149
|
+
|
|
150
|
+
gemini.embed(["First text", "Second text"], dimensions: 768)
|
|
151
|
+
AiLite::Gemini.embed("Search query", options: { taskType: "RETRIEVAL_QUERY" })
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The default is `gemini-embedding-001`. Override it with `embedding_model:` in the
|
|
155
|
+
constructor, `config.embedding_model`, or `model:` per call. Both bare model IDs
|
|
156
|
+
and `models/` prefixes are accepted. Model checks include the embedding model.
|
|
157
|
+
|
|
158
|
+
Single strings use `embedContent`; arrays use one synchronous `batchEmbedContents`
|
|
159
|
+
request. `dimensions:` maps to `outputDimensionality`. Native request fields in
|
|
160
|
+
`options:` apply to each input; explicit model, input, and dimensions take precedence.
|
|
161
|
+
See Google's [embedding API reference](https://ai.google.dev/api/embeddings).
|
|
162
|
+
|
|
163
|
+
Gemini returns numeric vectors; `encoding_format:` may be omitted or set to
|
|
164
|
+
`"float"`. Other formats return an error envelope. Empty inputs and invalid dimensions
|
|
165
|
+
also return errors before making a request. `debug: true` retains the provider
|
|
166
|
+
response and any usage metadata in `raw`; `response_id` is `nil` for embeddings.
|
|
167
|
+
|
|
168
|
+
### Gemini image generation
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
gemini = AiLite::Gemini.new
|
|
172
|
+
result = gemini.image("A cat astronaut on the moon", output_path: "tmp/cat.jpg")
|
|
173
|
+
result["content"] # Base64 image data, also returned when saving to a file
|
|
174
|
+
|
|
175
|
+
AiLite::Gemini.image("A mountain landscape", options: {
|
|
176
|
+
response_format: { type: "image", aspect_ratio: "16:9", image_size: "2K" }
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The default image model is `gemini-nano-banana-2.1`. Set `config.image_model`,
|
|
181
|
+
pass `image_model:` to the constructor, or override `model:` per call.
|
|
182
|
+
Model checks include this setting. Requests use the Interactions API, with native
|
|
183
|
+
settings passed through `options:`. Explicit prompt and model take precedence.
|
|
184
|
+
Image requests do not inherit the chat output-token limit.
|
|
185
|
+
|
|
186
|
+
Like OpenAI's `image`, `content` contains the first generated image as base64.
|
|
187
|
+
Only image blocks from `model_output` steps are selected. `output_path:` saves
|
|
188
|
+
its decoded bytes; the parent directory must already exist. Files retain the
|
|
189
|
+
provider's encoding: the filename does not convert the image. Use `debug: true`
|
|
190
|
+
to inspect MIME type, additional images, text, and usage in `raw`. Gemini can return
|
|
191
|
+
JPEG data even when the output filename ends in `.png`; choose the extension to
|
|
192
|
+
match the returned image MIME type. AI Lite does not transcode images.
|
|
193
|
+
|
|
194
|
+
The initial interface accepts a text prompt, `model:`, `output_path:`, `debug:`,
|
|
195
|
+
and `options:`. OpenAI-specific size, quality, background, and output format
|
|
196
|
+
keywords are not shared; use Gemini's native `response_format` options as above.
|
|
197
|
+
Streaming and background execution are rejected. Failed/incomplete interactions,
|
|
198
|
+
missing or invalid image data, and file-write errors return error envelopes.
|
|
199
|
+
See Google's [image generation guide](https://ai.google.dev/gemini-api/docs/image-generation).
|
|
200
|
+
|
|
124
201
|
### Gemini model checks
|
|
125
202
|
|
|
126
203
|
```ruby
|
|
@@ -129,7 +206,7 @@ result = gemini.available_models
|
|
|
129
206
|
puts result["content"] unless result["error"]
|
|
130
207
|
|
|
131
208
|
gemini.model_available?("gemini-3.8-flash") # true / false / nil on lookup failure
|
|
132
|
-
gemini.configured_models #
|
|
209
|
+
gemini.configured_models # Includes chat, embedding, and image models
|
|
133
210
|
gemini.check_configured_models # Standard envelope containing a report
|
|
134
211
|
|
|
135
212
|
# All four also work at class level:
|
|
@@ -2,15 +2,19 @@ class AiLite
|
|
|
2
2
|
class Gemini
|
|
3
3
|
API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta".freeze
|
|
4
4
|
DEFAULT_MODEL = "gemini-3.8-flash".freeze
|
|
5
|
+
DEFAULT_EMBEDDING_MODEL = "gemini-embedding-001".freeze
|
|
6
|
+
DEFAULT_IMAGE_MODEL = "gemini-nano-banana-2.1".freeze
|
|
5
7
|
DEFAULT_TIMEOUT = 120
|
|
6
8
|
DEFAULT_MAX_OUTPUT_TOKENS = 2000
|
|
7
9
|
|
|
8
10
|
class Configuration
|
|
9
|
-
attr_accessor :api_key, :model, :timeout, :max_output_tokens
|
|
11
|
+
attr_accessor :api_key, :model, :embedding_model, :image_model, :timeout, :max_output_tokens
|
|
10
12
|
|
|
11
13
|
def initialize
|
|
12
14
|
@api_key = nil
|
|
13
15
|
@model = DEFAULT_MODEL
|
|
16
|
+
@embedding_model = DEFAULT_EMBEDDING_MODEL
|
|
17
|
+
@image_model = DEFAULT_IMAGE_MODEL
|
|
14
18
|
@timeout = DEFAULT_TIMEOUT
|
|
15
19
|
@max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
|
|
16
20
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
class AiLite
|
|
2
|
+
class Gemini
|
|
3
|
+
module Embeddings
|
|
4
|
+
def embed(input, model: nil, dimensions: nil, encoding_format: nil, debug: false, options: {})
|
|
5
|
+
response = nil
|
|
6
|
+
raw = nil
|
|
7
|
+
multiple = input.is_a?(Array)
|
|
8
|
+
texts = multiple ? input : [input]
|
|
9
|
+
unless !texts.empty? && texts.all? { |text| text.is_a?(String) && !text.strip.empty? }
|
|
10
|
+
raise ArgumentError, "input must be a nonempty string or array of nonempty strings"
|
|
11
|
+
end
|
|
12
|
+
unless dimensions.nil? || (dimensions.is_a?(Integer) && dimensions > 0)
|
|
13
|
+
raise ArgumentError, "dimensions must be a positive integer"
|
|
14
|
+
end
|
|
15
|
+
unless encoding_format.nil? || encoding_format == "float"
|
|
16
|
+
raise ArgumentError, "Gemini embeddings only support encoding_format: float"
|
|
17
|
+
end
|
|
18
|
+
model_id = model || embedding_model
|
|
19
|
+
unless model_id.is_a?(String) && model_id.match?(%r{\A(?:models/)?[A-Za-z0-9._-]+\z})
|
|
20
|
+
raise ArgumentError, "model must be a Gemini model ID"
|
|
21
|
+
end
|
|
22
|
+
resource = "models/#{model_id.delete_prefix('models/')}"
|
|
23
|
+
settings = options.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
24
|
+
settings["outputDimensionality"] = dimensions if dimensions
|
|
25
|
+
requests = texts.map do |text|
|
|
26
|
+
settings.merge("model" => resource, "content" => { "parts" => [{ "text" => text }] })
|
|
27
|
+
end
|
|
28
|
+
action = multiple ? "batchEmbedContents" : "embedContent"
|
|
29
|
+
payload = multiple ? { "requests" => requests } : requests.first
|
|
30
|
+
response = post_json(payload, endpoint: "#{API_BASE_URL}/#{resource}:#{action}")
|
|
31
|
+
raw = response.body
|
|
32
|
+
raw = JSON.parse(raw)
|
|
33
|
+
raise "Invalid Gemini embedding response: expected an object" unless raw.is_a?(Hash)
|
|
34
|
+
if !response.code.to_i.between?(200, 299) || raw["error"]
|
|
35
|
+
return result_envelope(status: response.code.to_i, error: error_message(raw), raw: raw, debug: debug)
|
|
36
|
+
end
|
|
37
|
+
embeddings = multiple ? raw["embeddings"] : [raw["embedding"]]
|
|
38
|
+
unless embeddings.is_a?(Array) && embeddings.length == texts.length
|
|
39
|
+
raise "Invalid Gemini embedding response: unexpected embedding count"
|
|
40
|
+
end
|
|
41
|
+
vectors = embeddings.map do |entry|
|
|
42
|
+
values = entry["values"] if entry.is_a?(Hash)
|
|
43
|
+
unless values.is_a?(Array) && !values.empty? && values.all? { |value| value.is_a?(Numeric) }
|
|
44
|
+
raise "Invalid Gemini embedding response: expected numeric values"
|
|
45
|
+
end
|
|
46
|
+
values
|
|
47
|
+
end
|
|
48
|
+
result_envelope(status: response.code.to_i, content: multiple ? vectors : vectors.first,
|
|
49
|
+
raw: raw, debug: debug)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
result_envelope(status: response ? response.code.to_i : "unknown", error: e.message, raw: raw, debug: debug)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
include Embeddings
|
|
56
|
+
private_constant :Embeddings
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
class AiLite
|
|
2
|
+
class Gemini
|
|
3
|
+
module Images
|
|
4
|
+
def image(prompt, model: nil, output_path: nil, debug: false, options: {})
|
|
5
|
+
response = nil
|
|
6
|
+
raw = nil
|
|
7
|
+
response_id = nil
|
|
8
|
+
unless prompt.is_a?(String) && !prompt.strip.empty?
|
|
9
|
+
raise ArgumentError, "prompt must be a nonempty string"
|
|
10
|
+
end
|
|
11
|
+
payload = options.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
12
|
+
if payload["stream"] || payload["background"]
|
|
13
|
+
raise ArgumentError, "image does not support streaming or background execution"
|
|
14
|
+
end
|
|
15
|
+
payload.merge!("model" => model || image_model, "input" => prompt)
|
|
16
|
+
response = post_interaction(payload)
|
|
17
|
+
raw = response.body
|
|
18
|
+
raw = JSON.parse(raw)
|
|
19
|
+
raise "Invalid Gemini image response: expected an object" unless raw.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
response_id = raw["id"]
|
|
22
|
+
if !response.code.to_i.between?(200, 299) || raw["error"]
|
|
23
|
+
return result_envelope(status: response.code.to_i, error: error_message(raw),
|
|
24
|
+
response_id: response_id, raw: raw, debug: debug)
|
|
25
|
+
end
|
|
26
|
+
unless raw["status"] == "completed"
|
|
27
|
+
raise "Gemini interaction #{raw['status'] || 'has no status'}; inspect raw with debug: true"
|
|
28
|
+
end
|
|
29
|
+
steps = raw["steps"]
|
|
30
|
+
raise "Invalid Gemini image response: expected steps" unless steps.is_a?(Array)
|
|
31
|
+
|
|
32
|
+
image = steps.flat_map do |step|
|
|
33
|
+
next [] unless step.is_a?(Hash) && step["type"] == "model_output"
|
|
34
|
+
|
|
35
|
+
step["content"].is_a?(Array) ? step["content"] : []
|
|
36
|
+
end.find { |part| part.is_a?(Hash) && part["type"] == "image" }
|
|
37
|
+
content = image && image["data"]
|
|
38
|
+
unless content.is_a?(String) && !content.empty?
|
|
39
|
+
raise "No inline image data returned; inspect raw with debug: true"
|
|
40
|
+
end
|
|
41
|
+
bytes = Base64.strict_decode64(content)
|
|
42
|
+
raise "Empty image data returned" if bytes.empty?
|
|
43
|
+
|
|
44
|
+
File.binwrite(output_path, bytes) if output_path
|
|
45
|
+
result_envelope(status: response.code.to_i, content: content, response_id: response_id,
|
|
46
|
+
raw: raw, debug: debug)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
result_envelope(status: response ? response.code.to_i : "unknown", error: e.message,
|
|
49
|
+
response_id: response_id, raw: raw, debug: debug)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
include Images
|
|
54
|
+
private_constant :Images
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -2,7 +2,9 @@ class AiLite
|
|
|
2
2
|
class Gemini
|
|
3
3
|
module Models
|
|
4
4
|
def self.snapshot(source)
|
|
5
|
-
{ "chat" => source.model
|
|
5
|
+
{ "chat" => source.model, "embedding" => source.embedding_model, "image" => source.image_model }.transform_values do |model|
|
|
6
|
+
model.is_a?(String) ? model.dup : model
|
|
7
|
+
end
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def configured_models
|
data/lib/ai_lite/gemini.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "base64"
|
|
4
5
|
require_relative "version"
|
|
5
6
|
require_relative "gemini/configuration"
|
|
6
7
|
require_relative "gemini/chat"
|
|
8
|
+
require_relative "gemini/embeddings"
|
|
9
|
+
require_relative "gemini/images"
|
|
7
10
|
require_relative "gemini/streaming"
|
|
8
11
|
require_relative "gemini/youtube"
|
|
9
12
|
require_relative "gemini/models"
|
|
@@ -43,19 +46,29 @@ class AiLite
|
|
|
43
46
|
client.chat_stream(message, **kwargs, &block)
|
|
44
47
|
end
|
|
45
48
|
|
|
49
|
+
def image(prompt, **kwargs)
|
|
50
|
+
client.image(prompt, **kwargs)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def embed(input, **kwargs)
|
|
54
|
+
client.embed(input, **kwargs)
|
|
55
|
+
end
|
|
56
|
+
|
|
46
57
|
def chat(message, **kwargs)
|
|
47
58
|
client.chat(message, **kwargs)
|
|
48
59
|
end
|
|
49
60
|
end
|
|
50
61
|
|
|
51
|
-
attr_reader :api_key, :model, :timeout, :max_output_tokens, :headers
|
|
62
|
+
attr_reader :api_key, :model, :embedding_model, :image_model, :timeout, :max_output_tokens, :headers
|
|
52
63
|
|
|
53
|
-
def initialize(api_key: nil, model: nil, timeout: nil, max_output_tokens: nil)
|
|
64
|
+
def initialize(api_key: nil, model: nil, embedding_model: nil, image_model: nil, timeout: nil, max_output_tokens: nil)
|
|
54
65
|
config = self.class.configuration
|
|
55
66
|
@api_key = api_key || config.api_key || ENV["GEMINI_API_KEY"]
|
|
56
67
|
raise ArgumentError, "Missing Gemini API key" if @api_key.to_s.strip.empty?
|
|
57
68
|
|
|
58
69
|
@model = model || config.model
|
|
70
|
+
@embedding_model = embedding_model || config.embedding_model
|
|
71
|
+
@image_model = image_model || config.image_model
|
|
59
72
|
@timeout = timeout || config.timeout
|
|
60
73
|
@max_output_tokens = max_output_tokens || config.max_output_tokens
|
|
61
74
|
@headers = { "x-goog-api-key" => @api_key, "Content-Type" => "application/json" }
|
|
@@ -64,7 +77,11 @@ class AiLite
|
|
|
64
77
|
private
|
|
65
78
|
|
|
66
79
|
def post_interaction(payload)
|
|
67
|
-
|
|
80
|
+
post_json(payload, endpoint: "#{API_BASE_URL}/interactions")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def post_json(payload, endpoint:)
|
|
84
|
+
uri = URI.parse(endpoint)
|
|
68
85
|
request = Net::HTTP::Post.new(uri)
|
|
69
86
|
headers.each { |key, value| request[key] = value }
|
|
70
87
|
request.body = JSON.generate(payload)
|
data/lib/ai_lite/version.rb
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class GeminiEmbeddingsTest < Minitest::Test
|
|
4
|
+
Response = Struct.new(:code, :body)
|
|
5
|
+
class Http
|
|
6
|
+
attr_accessor :open_timeout, :read_timeout
|
|
7
|
+
attr_reader :request_received
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@response = response
|
|
10
|
+
end
|
|
11
|
+
def request(request)
|
|
12
|
+
@request_received = request
|
|
13
|
+
raise @response if @response.is_a?(Exception)
|
|
14
|
+
@response
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
AiLite::Gemini.reset_configuration!
|
|
20
|
+
AiLite::OpenAI.reset_configuration!
|
|
21
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_http(raw = {}, status = "200")
|
|
25
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
26
|
+
http = Http.new(response)
|
|
27
|
+
original = Net::HTTP.method(:start)
|
|
28
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
29
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
30
|
+
block.call(http)
|
|
31
|
+
end
|
|
32
|
+
yield http
|
|
33
|
+
ensure
|
|
34
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_single_embedding_transport_and_envelope
|
|
38
|
+
with_http({ "embedding" => { "values" => [0.1, -0.2] } }) do |http|
|
|
39
|
+
result = @client.embed("Hello")
|
|
40
|
+
assert_equal({ "content" => [0.1, -0.2], "status" => 200, "error" => nil,
|
|
41
|
+
"raw" => nil, "response_id" => nil }, result)
|
|
42
|
+
assert_equal "/v1beta/models/gemini-embedding-001:embedContent", http.request_received.path
|
|
43
|
+
assert_equal "gemini-key", http.request_received["x-goog-api-key"]
|
|
44
|
+
assert_equal "application/json", http.request_received["Content-Type"]
|
|
45
|
+
assert_equal 9, http.read_timeout
|
|
46
|
+
assert_equal 9, http.open_timeout
|
|
47
|
+
assert_equal({ "model" => "models/gemini-embedding-001", "content" => { "parts" => [{ "text" => "Hello" }] } }, JSON.parse(http.request_received.body))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_batch_order_options_dimensions_and_debug
|
|
52
|
+
options = { taskType: "RETRIEVAL_DOCUMENT", title: "Guide", "outputDimensionality" => 8 }
|
|
53
|
+
original = Marshal.dump(options)
|
|
54
|
+
raw = { "embeddings" => [{ "values" => [1, 2] }, { "values" => [3, 4] }], "usageMetadata" => { "totalTokenCount" => 4 } }
|
|
55
|
+
with_http(raw) do |http|
|
|
56
|
+
result = @client.embed(["First", "Second"], model: "models/custom", dimensions: 2, encoding_format: "float", options: options, debug: true)
|
|
57
|
+
assert_equal [[1, 2], [3, 4]], result["content"]
|
|
58
|
+
assert_equal raw, result["raw"]
|
|
59
|
+
assert_equal "/v1beta/models/custom:batchEmbedContents", http.request_received.path
|
|
60
|
+
requests = JSON.parse(http.request_received.body)["requests"]
|
|
61
|
+
assert_equal ["First", "Second"], requests.map { |r| r.dig("content", "parts", 0, "text") }
|
|
62
|
+
requests.each do |request|
|
|
63
|
+
assert_equal "models/custom", request["model"]
|
|
64
|
+
assert_equal 2, request["outputDimensionality"]
|
|
65
|
+
assert_equal "RETRIEVAL_DOCUMENT", request["taskType"]
|
|
66
|
+
assert_equal "Guide", request["title"]
|
|
67
|
+
refute request.key?("encoding_format")
|
|
68
|
+
end
|
|
69
|
+
assert_equal original, Marshal.dump(options)
|
|
70
|
+
end
|
|
71
|
+
with_http({ "embeddings" => [{ "values" => [1] }] }) do
|
|
72
|
+
assert_equal [[1]], @client.embed(["Only one"])["content"]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_configuration_delegation_and_model_checks
|
|
77
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.embedding_model = "configured" }
|
|
78
|
+
assert_equal "configured", AiLite::Gemini.client.embedding_model
|
|
79
|
+
assert_equal "gemini-embedding-001", @client.embedding_model
|
|
80
|
+
assert_equal "override", AiLite::Gemini.new(embedding_model: "override").embedding_model
|
|
81
|
+
snapshot = AiLite::Gemini.configured_models
|
|
82
|
+
snapshot["embedding"].replace("changed")
|
|
83
|
+
assert_equal "configured", AiLite::Gemini.configuration.embedding_model
|
|
84
|
+
with_http({ "embedding" => { "values" => [1] } }) do |http|
|
|
85
|
+
assert_equal [1], AiLite::Gemini.embed("Hello")["content"]
|
|
86
|
+
assert_equal "/v1beta/models/configured:embedContent", http.request_received.path
|
|
87
|
+
end
|
|
88
|
+
with_http({ "models" => [{ "name" => "models/configured" }] }) do
|
|
89
|
+
assert_equal true, AiLite::Gemini.check_configured_models.dig("content", "embedding", "available")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_invalid_inputs_do_not_make_requests
|
|
94
|
+
with_http do |http|
|
|
95
|
+
[nil, [], "", ["ok", nil], { text: "hi" }].each { |input| refute_nil @client.embed(input)["error"] }
|
|
96
|
+
[{ dimensions: 0 }, { dimensions: "2" }, { encoding_format: "base64" }, { model: "bad/path" }].each do |kwargs|
|
|
97
|
+
refute_nil @client.embed("Hello", **kwargs)["error"]
|
|
98
|
+
end
|
|
99
|
+
assert_nil http.request_received
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_api_transport_and_malformed_response_errors
|
|
104
|
+
with_http({ "error" => { "message" => "Denied" } }, "403") do
|
|
105
|
+
result = @client.embed("Hello", debug: true)
|
|
106
|
+
assert_equal 403, result["status"]
|
|
107
|
+
assert_equal "Denied", result["error"]
|
|
108
|
+
refute_nil result["raw"]
|
|
109
|
+
end
|
|
110
|
+
["invalid json", [], {}, { "embedding" => { "values" => ["bad"] } }, IOError.new("Disconnected")].each do |raw|
|
|
111
|
+
with_http(raw) do
|
|
112
|
+
result = @client.embed("Hello")
|
|
113
|
+
refute_nil result["error"]
|
|
114
|
+
assert_nil result["content"]
|
|
115
|
+
assert_nil result["raw"]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
with_http({ "embeddings" => [{ "values" => [1] }] }) do
|
|
119
|
+
assert_match(/count/, @client.embed(["one", "two"])["error"])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
|
|
4
|
+
class GeminiImagesTest < Minitest::Test
|
|
5
|
+
Response = Struct.new(:code, :body)
|
|
6
|
+
class Http
|
|
7
|
+
attr_accessor :open_timeout, :read_timeout
|
|
8
|
+
attr_reader :request_received
|
|
9
|
+
def initialize(response)
|
|
10
|
+
@response = response
|
|
11
|
+
end
|
|
12
|
+
def request(request)
|
|
13
|
+
@request_received = request
|
|
14
|
+
raise @response if @response.is_a?(Exception)
|
|
15
|
+
@response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def setup
|
|
20
|
+
AiLite::Gemini.reset_configuration!
|
|
21
|
+
AiLite::OpenAI.reset_configuration!
|
|
22
|
+
@client = AiLite::Gemini.new(api_key: "gemini-key", timeout: 9)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_http(raw = {}, status = "200")
|
|
26
|
+
response = raw.is_a?(Exception) ? raw : Response.new(status, raw.is_a?(String) ? raw : JSON.generate(raw))
|
|
27
|
+
http = Http.new(response)
|
|
28
|
+
original = Net::HTTP.method(:start)
|
|
29
|
+
Net::HTTP.define_singleton_method(:start) do |host, port, use_ssl:, &block|
|
|
30
|
+
raise "Wrong provider host" unless host == "generativelanguage.googleapis.com" && port == 443 && use_ssl
|
|
31
|
+
block.call(http)
|
|
32
|
+
end
|
|
33
|
+
yield http
|
|
34
|
+
ensure
|
|
35
|
+
Net::HTTP.define_singleton_method(:start, original)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
PNG = Base64.strict_decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=")
|
|
39
|
+
|
|
40
|
+
def image_body
|
|
41
|
+
{ "id" => "image-123", "status" => "completed", "steps" => [
|
|
42
|
+
{ "type" => "thought", "content" => [{ "type" => "image", "data" => Base64.strict_encode64("hidden") }] },
|
|
43
|
+
{ "type" => "model_output", "content" => [
|
|
44
|
+
{ "type" => "text", "text" => "Here is your image" },
|
|
45
|
+
{ "type" => "image", "mime_type" => "image/png", "data" => Base64.strict_encode64(PNG) },
|
|
46
|
+
{ "type" => "image", "mime_type" => "image/png", "data" => Base64.strict_encode64("second") }
|
|
47
|
+
] }
|
|
48
|
+
] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_default_transport_first_image_and_envelope
|
|
52
|
+
with_http(image_body) do |http|
|
|
53
|
+
result = @client.image("A cat")
|
|
54
|
+
assert_equal({ "content" => Base64.strict_encode64(PNG), "status" => 200,
|
|
55
|
+
"error" => nil, "response_id" => "image-123", "raw" => nil }, result)
|
|
56
|
+
request = http.request_received
|
|
57
|
+
assert_equal "/v1beta/interactions", request.path
|
|
58
|
+
assert_equal "gemini-key", request["x-goog-api-key"]
|
|
59
|
+
assert_equal "application/json", request["Content-Type"]
|
|
60
|
+
assert_equal({ "model" => AiLite::Gemini::DEFAULT_IMAGE_MODEL, "input" => "A cat" }, JSON.parse(request.body))
|
|
61
|
+
assert_equal 9, http.open_timeout
|
|
62
|
+
assert_equal 9, http.read_timeout
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_native_options_precedence_debug_and_file_output
|
|
67
|
+
options = { model: "ignored", "input" => "ignored", response_format: { type: "image", aspect_ratio: "16:9", image_size: "2K" }, store: false }
|
|
68
|
+
original = Marshal.dump(options)
|
|
69
|
+
Dir.mktmpdir do |dir|
|
|
70
|
+
path = File.join(dir, "cat.png")
|
|
71
|
+
with_http(image_body) do |http|
|
|
72
|
+
result = @client.image("A cat", model: "custom-image", output_path: path, options: options, debug: true)
|
|
73
|
+
assert_nil result["error"]
|
|
74
|
+
assert_equal PNG, File.binread(path)
|
|
75
|
+
assert_equal Base64.strict_encode64(PNG), result["content"]
|
|
76
|
+
assert_equal image_body, result["raw"]
|
|
77
|
+
payload = JSON.parse(http.request_received.body)
|
|
78
|
+
assert_equal "custom-image", payload["model"]
|
|
79
|
+
assert_equal "A cat", payload["input"]
|
|
80
|
+
assert_equal false, payload["store"]
|
|
81
|
+
assert_equal "16:9", payload.dig("response_format", "aspect_ratio")
|
|
82
|
+
assert_equal "2K", payload.dig("response_format", "image_size")
|
|
83
|
+
refute payload.key?("generation_config")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
assert_equal original, Marshal.dump(options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_configuration_delegation_and_model_checks
|
|
90
|
+
AiLite::Gemini.configure { |c| c.api_key = "key"; c.image_model = "configured-image" }
|
|
91
|
+
assert_equal "configured-image", AiLite::Gemini.client.image_model
|
|
92
|
+
assert_equal AiLite::Gemini::DEFAULT_IMAGE_MODEL, @client.image_model
|
|
93
|
+
assert_equal "override", AiLite::Gemini.new(image_model: "override").image_model
|
|
94
|
+
original = AiLite::OpenAI.configured_models
|
|
95
|
+
snapshot = AiLite::Gemini.configured_models
|
|
96
|
+
snapshot["image"].replace("changed")
|
|
97
|
+
assert_equal "configured-image", AiLite::Gemini.configuration.image_model
|
|
98
|
+
with_http(image_body) do |http|
|
|
99
|
+
assert_nil AiLite::Gemini.image("A cat")["error"]
|
|
100
|
+
assert_equal "configured-image", JSON.parse(http.request_received.body)["model"]
|
|
101
|
+
end
|
|
102
|
+
with_http({ "models" => [{ "name" => "models/configured-image" }] }) do
|
|
103
|
+
assert_equal true, AiLite::Gemini.check_configured_models.dig("content", "image", "available")
|
|
104
|
+
end
|
|
105
|
+
assert_equal original, AiLite::OpenAI.configured_models
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_invalid_inputs_and_execution_modes_do_not_send_requests
|
|
109
|
+
with_http do |http|
|
|
110
|
+
[nil, " ", [], {}].each { |prompt| refute_nil @client.image(prompt)["error"] }
|
|
111
|
+
[{ stream: true }, { "background" => true }].each do |options|
|
|
112
|
+
refute_nil @client.image("A cat", options: options)["error"]
|
|
113
|
+
end
|
|
114
|
+
assert_nil http.request_received
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_errors_do_not_overwrite_output
|
|
119
|
+
malformed = image_body
|
|
120
|
+
malformed["steps"][1]["content"][1]["data"] = "invalid base64!"
|
|
121
|
+
no_image = image_body.merge("steps" => [{ "type" => "model_output", "content" => [{ "type" => "text", "text" => "Cannot generate" }] }])
|
|
122
|
+
responses = [malformed, no_image, "invalid JSON", [], {}, IOError.new("Disconnected")]
|
|
123
|
+
%w[failed incomplete cancelled requires_action queued in_progress].each do |state|
|
|
124
|
+
responses << image_body.merge("status" => state)
|
|
125
|
+
end
|
|
126
|
+
Dir.mktmpdir do |dir|
|
|
127
|
+
path = File.join(dir, "existing.png")
|
|
128
|
+
File.binwrite(path, "original")
|
|
129
|
+
responses.each do |raw|
|
|
130
|
+
with_http(raw) do
|
|
131
|
+
result = @client.image("A cat", output_path: path)
|
|
132
|
+
refute_nil result["error"]
|
|
133
|
+
assert_nil result["content"]
|
|
134
|
+
assert_nil result["raw"]
|
|
135
|
+
assert_equal "original", File.binread(path)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
with_http(image_body) do
|
|
139
|
+
result = @client.image("A cat", output_path: File.join(dir, "missing", "cat.png"), debug: true)
|
|
140
|
+
refute_nil result["error"]
|
|
141
|
+
assert_equal "image-123", result["response_id"]
|
|
142
|
+
assert_equal image_body, result["raw"]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
["403", "200"].each do |status|
|
|
146
|
+
with_http({ "error" => { "message" => "Denied" } }, status) do
|
|
147
|
+
result = @client.image("A cat", debug: true)
|
|
148
|
+
assert_equal "Denied", result["error"]
|
|
149
|
+
assert_equal status.to_i, result["status"]
|
|
150
|
+
refute_nil result["raw"]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/test/gemini_models_test.rb
CHANGED
|
@@ -65,7 +65,7 @@ class GeminiModelsTest < Minitest::Test
|
|
|
65
65
|
end
|
|
66
66
|
def test_local_configuration_and_single_paginated_check
|
|
67
67
|
with_pages do |http|
|
|
68
|
-
assert_equal({ "chat" => AiLite::Gemini::DEFAULT_MODEL }, AiLite::Gemini.configured_models)
|
|
68
|
+
assert_equal({ "chat" => AiLite::Gemini::DEFAULT_MODEL, "embedding" => AiLite::Gemini::DEFAULT_EMBEDDING_MODEL, "image" => AiLite::Gemini::DEFAULT_IMAGE_MODEL }, AiLite::Gemini.configured_models)
|
|
69
69
|
snapshot = @client.configured_models
|
|
70
70
|
snapshot["chat"].replace("changed")
|
|
71
71
|
assert_equal "models/test-model", @client.model
|
|
@@ -74,7 +74,8 @@ class GeminiModelsTest < Minitest::Test
|
|
|
74
74
|
with_pages(page(["other"], "next"), page(["test-model"])) do |http|
|
|
75
75
|
result = @client.check_configured_models
|
|
76
76
|
assert_equal 2, http.requests.size
|
|
77
|
-
assert_equal({ "chat" => { "model" => "models/test-model", "usage" => "chat", "available" => true, "error" => nil } }, result["content"])
|
|
77
|
+
assert_equal({ "chat" => { "model" => "models/test-model", "usage" => "chat", "available" => true, "error" => nil } }, result["content"].slice("chat"))
|
|
78
|
+
assert_equal false, result.dig("content", "embedding", "available")
|
|
78
79
|
assert_nil result["raw"]
|
|
79
80
|
end
|
|
80
81
|
end
|
data/test/gemini_test.rb
CHANGED
|
@@ -130,7 +130,7 @@ class GeminiTest < Minitest::Test
|
|
|
130
130
|
assert_same openai, AiLite::OpenAI.client
|
|
131
131
|
assert_equal "openai-key", openai.api_key
|
|
132
132
|
refute AiLite::Gemini < AiLite::OpenAI
|
|
133
|
-
%i[moderate transcribe
|
|
133
|
+
%i[moderate transcribe speak].each { |method| refute_respond_to gemini, method }
|
|
134
134
|
with_http do
|
|
135
135
|
assert_equal "Hello", AiLite::Gemini.chat("Hello")["content"]
|
|
136
136
|
end
|
metadata
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ai-lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.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-10-
|
|
11
|
+
date: 2026-10-23 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, using only the Ruby standard library. It provides OpenAI chat, streaming,
|
|
15
15
|
moderation, embeddings, images, speech, transcription, and model checks, plus Gemini
|
|
16
|
-
chat, streaming, structured media inputs, and model
|
|
16
|
+
chat, streaming, embeddings, image generation, structured media inputs, and model
|
|
17
|
+
checks.
|
|
17
18
|
email:
|
|
18
19
|
executables: []
|
|
19
20
|
extensions: []
|
|
@@ -26,6 +27,8 @@ files:
|
|
|
26
27
|
- lib/ai_lite/gemini.rb
|
|
27
28
|
- lib/ai_lite/gemini/chat.rb
|
|
28
29
|
- lib/ai_lite/gemini/configuration.rb
|
|
30
|
+
- lib/ai_lite/gemini/embeddings.rb
|
|
31
|
+
- lib/ai_lite/gemini/images.rb
|
|
29
32
|
- lib/ai_lite/gemini/models.rb
|
|
30
33
|
- lib/ai_lite/gemini/streaming.rb
|
|
31
34
|
- lib/ai_lite/gemini/youtube.rb
|
|
@@ -35,6 +38,8 @@ files:
|
|
|
35
38
|
- lib/ai_lite/openai/streaming.rb
|
|
36
39
|
- lib/ai_lite/version.rb
|
|
37
40
|
- test/ai_lite_test.rb
|
|
41
|
+
- test/gemini_embeddings_test.rb
|
|
42
|
+
- test/gemini_images_test.rb
|
|
38
43
|
- test/gemini_models_test.rb
|
|
39
44
|
- test/gemini_streaming_test.rb
|
|
40
45
|
- test/gemini_test.rb
|