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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d320744aa3ea523ee76415327cad74343186881e21e28910b6b13a556d46eef3
4
- data.tar.gz: e93d793fdeeefb1c8a2498906b48826934eee3981cadeb4f7073005e0140bd41
3
+ metadata.gz: a6b2ed589de27af46e6ce7babfb4a1ec32808ecfae567f29ed8db35bfd129f26
4
+ data.tar.gz: ddee8996d702a77709b30bd9bb360994abfbc3e4875386db04b99241541a413d
5
5
  SHA512:
6
- metadata.gz: 4fa921ce3f81bdaed04e0facab46c8efdb863b98299d9452ba9f6bdac90d3a9f302e5850e067529b7d66f9552ff41945bb75f38257d6515ba761673c0d8f71d5
7
- data.tar.gz: 0575ce9fb53e2f419940c08a6626a4d528a5661d7cef2e5cbca7c3b9b22587c0288ea4db37b69d6bf03fd8d470c66de68df6515fb226ceb77483b2beb91a90ab
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 embeddings, image generation, speech, file transcription, additional
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, dependency-light client with separate OpenAI and Gemini providers.
4
- OpenAI supports the full utility methods below; Gemini supports chat and streaming with text and structured media input.
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 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.
7
-
8
- Use AI Lite when you want a small OpenAI client that works without tying your app to a specific Rails version or a larger dependency stack.
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 official OpenAI gem dependency
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
- It is not meant to replace the official OpenAI SDK. It is a small wrapper for projects that only need a few clean interfaces:
18
+ Choose a provider and call the methods your app needs:
18
19
 
19
20
  ```ruby
20
- ai.chat("Say hello")
21
- ai.moderate("User submitted text")
22
- ai.embed("Text to vectorize")
23
- ai.image("A simple app icon")
24
- ai.speak("Read this aloud")
25
- ai.transcribe("tmp/meeting.mp3")
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
- | `moderate`, `embed`, `image`, `speak`, `transcribe` | Yes | Not implemented |
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 embeddings and image/speech methods are not implemented yet. Use `chat_stream` for streaming; `chat` rejects
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 # { "chat" => "gemini-3.8-flash" }
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.is_a?(String) ? source.model.dup : 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
@@ -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
- uri = URI.parse("#{API_BASE_URL}/interactions")
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)
@@ -1,3 +1,3 @@
1
1
  class AiLite
2
- VERSION = "1.0.0".freeze
2
+ VERSION = "1.1.0".freeze
3
3
  end
@@ -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
@@ -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 image speak embed].each { |method| refute_respond_to gemini, method }
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.0.0
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-10 00:00:00.000000000 Z
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 checks.
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