ai-lite 0.6.1 → 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: 6f7c5581a41c1371b086b33502200da2ad06cab07cc202efd0f943616effb738
4
- data.tar.gz: 3a1c897735f7aa945e4cca0fb3bea1a86c3beefc7e823e8a149b78e93ac3b7c8
3
+ metadata.gz: a6b2ed589de27af46e6ce7babfb4a1ec32808ecfae567f29ed8db35bfd129f26
4
+ data.tar.gz: ddee8996d702a77709b30bd9bb360994abfbc3e4875386db04b99241541a413d
5
5
  SHA512:
6
- metadata.gz: 6c5f349cdf43703435f4ab0c08216ce59c73737042ad179ed675339a37238b175dffdc96d5a0014ac0a89dca554e4ed4d4279c417bd13a25fd9dc80d4d9dadc7
7
- data.tar.gz: 0475ffdba930c5a5dc6c463f78c6abfc7c14306b233b1d05c17e6e6aa5f20bb706271c994e4aec0f2f350f656bcda3c681716751e280968ec8962222cfdea432
6
+ metadata.gz: 9591556daadccbb5107f8b52f6d5ca825232003336a87db144e41971ec9921282a38ea884928d0d1f5ee8ea4d84fb8b67698ddcfb1894f678ab2bc615a9dcdb8
7
+ data.tar.gz: 24aa31e8ddaa48bf6876bc7e4375c212d46994d37709a32bb52c797b3618f0dc770fe831919039781c2f9529b40ff0c85b12c04dea4a7894c61fa4e8515fa282
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
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
+
11
+ ## 1.0.0 (unreleased)
12
+
13
+ - Introduce independent `AiLite::OpenAI` and `AiLite::Gemini` providers.
14
+ - Preserve documented legacy OpenAI entry points through `AiLite`, with deprecation
15
+ warnings once per entry point per process. Removal is planned for v2.0.
16
+ - Add OpenAI and Gemini text streaming through `chat_stream`.
17
+ - Add `available_models`, `model_available?`, `configured_models`, and
18
+ `check_configured_models` to both providers; Gemini discovery follows all pages.
19
+ - Add Gemini chat with native structured media input and interaction continuation,
20
+ plus a YouTube URL helper. Existing OpenAI utility methods remain available.
21
+ - Document model-list limitations, provider-specific debug output, and migration.
22
+ - Treat failed/incomplete OpenAI generations as errors even when HTTP status is 200.
23
+ - Handle fragmented UTF-8 and mixed SSE line endings in both stream parsers.
24
+
25
+ Gemini image generation, speech, file transcription, additional
26
+ providers, and async convenience methods are deferred beyond v1.
data/README.md CHANGED
@@ -1,35 +1,330 @@
1
1
  # AI Lite
2
2
 
3
- AI Lite is a pure Ruby, dependency-light client for simple OpenAI API calls.
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.
4
6
 
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
-
7
- 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.
8
10
 
9
11
  This gem is intentionally small:
10
12
 
11
13
  - No Rails dependency
12
- - No official OpenAI gem dependency
14
+ - No provider SDK dependency
13
15
  - No Faraday, HTTParty, ActiveSupport, or connection pool dependency
14
16
  - Uses only Ruby stdlib: `Net::HTTP`, `URI`, `JSON`, `Base64`, and `SecureRandom`
15
17
 
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:
18
+ Choose a provider and call the methods your app needs:
19
+
20
+ ```ruby
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")
34
+ ```
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
+
40
+ ## Provider support
41
+
42
+ | Method | OpenAI | Gemini |
43
+ | --- | --- | --- |
44
+ | `chat`, `chat_stream` | Yes | Yes |
45
+ | Four model helpers | Yes | Yes |
46
+ | `youtube` | No | Yes |
47
+ | `embed`, `image` | Yes | Yes |
48
+ | `moderate`, `speak`, `transcribe` | Yes | Not implemented |
49
+
50
+ Unsupported provider methods are absent and raise Ruby's `NoMethodError` if called.
51
+ Configure providers during application startup; do not mutate shared configuration
52
+ or client headers while requests are running. The gem does not provide automatic
53
+ retries, worker threads, or a background-job queue.
54
+
55
+ ## Gemini
56
+
57
+ ```ruby
58
+ require "ai_lite"
59
+
60
+ gemini = AiLite::Gemini.new # Reads GEMINI_API_KEY
61
+ result = gemini.chat("Tell me a joke")
62
+ puts result["content"]
63
+ warn result["error"] if result["error"]
64
+ ```
65
+
66
+ The lookup order is an explicit `api_key:`, Gemini configuration, then
67
+ `ENV["GEMINI_API_KEY"]`. OpenAI credentials are never used for Gemini. The gem
68
+ does not load `.env` automatically.
69
+
70
+ ```ruby
71
+ AiLite::Gemini.configure do |config|
72
+ config.api_key = ENV["GEMINI_API_KEY"]
73
+ config.model = "gemini-3.8-flash"
74
+ config.embedding_model = "gemini-embedding-001"
75
+ config.image_model = "gemini-nano-banana-2.1"
76
+ config.timeout = 120
77
+ config.max_output_tokens = 2000
78
+ end
79
+
80
+ AiLite::Gemini.chat("Tell me a joke")
81
+ ```
82
+
83
+ Each provider has independent configuration and a cached client. Constructor
84
+ keywords override configured defaults. Configuration changes reset that provider's
85
+ class-level client; existing instances keep their settings. Gemini uses the
86
+ Interactions API at `https://generativelanguage.googleapis.com/v1beta/interactions`
87
+ with `x-goog-api-key` authentication. Its API version and defaults belong to Gemini.
88
+
89
+ `chat` accepts a string, a native Gemini content hash, or an array of native
90
+ content blocks or interaction steps. Structured inputs pass through unchanged:
91
+
92
+ ```ruby
93
+ result = gemini.chat([
94
+ { type: "video", uri: "https://www.youtube.com/watch?v=YOUR_VIDEO_ID" },
95
+ { type: "text", text: "Summarize the key points." }
96
+ ])
97
+ ```
98
+
99
+ Google validates media access and model support. This does not download videos,
100
+ upload local files, or make arbitrary video webpages accessible. Message history
101
+ uses Gemini's native `user_input` / `model_output` steps with `content` arrays;
102
+ OpenAI-style role/message arrays are not automatically translated.
103
+
104
+ Multi-turn continuation uses the familiar keyword:
105
+
106
+ ```ruby
107
+ first = gemini.chat("My name is William.")
108
+ second = gemini.chat("What is my name?", previous_response_id: first["response_id"])
109
+ ```
110
+
111
+ `instructions:` maps to `system_instruction`; `previous_response_id:` maps to
112
+ `previous_interaction_id`; `max_output_tokens:` maps into `generation_config`.
113
+ Gemini-specific settings remain available through `options:`:
114
+
115
+ ```ruby
116
+ gemini.chat("Say hello", instructions: "Be concise.", options: {
117
+ store: false,
118
+ generation_config: { temperature: 0.4 }
119
+ })
120
+ ```
121
+
122
+ Interactions are stored by default by the provider; `store: false` opts out and
123
+ prevents later server-side continuation from that interaction. See Google's
124
+ [Interactions guide](https://ai.google.dev/gemini-api/docs/interactions-overview)
125
+ and [API reference](https://ai.google.dev/api/interactions-api).
126
+
127
+ Results use the standard `content`, `response_id`, `status`, `error`, and `raw`
128
+ keys. Text is extracted only from `model_output` steps; JSON-looking text is
129
+ parsed. `debug: true` retains the complete response, including non-text output,
130
+ tool calls, and usage. Non-completed interactions (including `requires_action`)
131
+ return an error rather than pretending the answer is complete; no tools are
132
+ executed automatically. Input/API/transport errors return an error envelope;
133
+ constructing a client without a key raises `ArgumentError`.
134
+
135
+ Gemini speech and file transcription methods are not implemented yet. Use `chat_stream` for streaming; `chat` rejects
136
+ `stream: true`. Both methods reject `background: true` before sending a request.
137
+ Legacy `AiLite` entry points still use OpenAI.
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
+
201
+ ### Gemini model checks
202
+
203
+ ```ruby
204
+ gemini = AiLite::Gemini.new
205
+ result = gemini.available_models
206
+ puts result["content"] unless result["error"]
207
+
208
+ gemini.model_available?("gemini-3.8-flash") # true / false / nil on lookup failure
209
+ gemini.configured_models # Includes chat, embedding, and image models
210
+ gemini.check_configured_models # Standard envelope containing a report
211
+
212
+ # All four also work at class level:
213
+ AiLite::Gemini.available_models(debug: true)
214
+ AiLite::Gemini.model_available?("gemini-3.8-flash")
215
+ AiLite::Gemini.configured_models # No key or network required
216
+ AiLite::Gemini.check_configured_models
217
+ ```
218
+
219
+ `available_models` follows every page of Gemini's `/v1beta/models` endpoint and
220
+ returns unique model IDs in `content`, with the `models/` resource prefix removed.
221
+ `model_available?` accepts IDs with or without that prefix, uses exact matching
222
+ (no alias resolution), and fetches a fresh complete list. Invalid empty/non-string
223
+ IDs raise `ArgumentError`. Lookup errors return `nil`; inspect `available_models`
224
+ or `check_configured_models` for error details.
225
+
226
+ `configured_models` returns a local snapshot of the instance's settings; its
227
+ class method reports current Gemini configuration. Currently only `chat` is
228
+ configured: `chat_stream` and `youtube` use that same model.
229
+
230
+ `check_configured_models` fetches the list once across its pages, then returns:
231
+
232
+ ```ruby
233
+ result["content"]["chat"]
234
+ # { "model" => "gemini-3.8-flash", "usage" => "chat",
235
+ # "available" => true, "error" => nil } # if listed
236
+ ```
237
+
238
+ Any failed page makes the entire lookup fail; partial lists are not treated as
239
+ complete. Reports use `available: nil` on failure, with both entry-level and
240
+ outer errors. `debug: true` includes original page responses in
241
+ `raw["pages"]`, preserving model metadata and any received error response.
242
+ These methods make only model-list GET requests, never generation requests.
243
+ They do not verify free-tier eligibility, quota, endpoint compatibility, or
244
+ whether a model is temporarily overloaded. See Google's
245
+ [Models API documentation](https://ai.google.dev/api/models).
246
+
247
+ ### YouTube helper
17
248
 
18
249
  ```ruby
19
- ai.chat("Say hello")
20
- ai.moderate("User submitted text")
21
- ai.embed("Text to vectorize")
22
- ai.image("A simple app icon")
23
- ai.speak("Read this aloud")
24
- ai.transcribe("tmp/meeting.mp3")
250
+ result = gemini.youtube(
251
+ "https://www.youtube.com/watch?v=DV69qh0BzoA",
252
+ prompt: "Summarize this video in five bullet points.",
253
+ model: "gemini-3.5-flash-lite"
254
+ )
255
+ puts result["content"]
256
+ warn result["error"] if result["error"]
25
257
  ```
26
258
 
259
+ `AiLite::Gemini.youtube(url, prompt: ..., **kwargs)` also works. The helper
260
+ validates one HTTPS YouTube video URL and a nonempty prompt, builds native video
261
+ and text input, and delegates to `chat`. It accepts the same chat keywords,
262
+ including `model:`, `instructions:`, `previous_response_id:`, `max_output_tokens:`,
263
+ `debug:`, and `options:`, and returns the same envelope.
264
+
265
+ Supported URL forms are `youtube.com/watch?v=...` (including `www` and `m`),
266
+ `youtu.be/...`, and YouTube `/shorts/`, `/embed/`, and `/live/` video paths.
267
+ URLs, including timestamp parameters, pass through unchanged. Validation checks
268
+ URL shape and video ID, not whether the video is public or accessible to Gemini.
269
+ The helper builds the same request as this explicit input:
270
+
271
+ ```ruby
272
+ gemini.chat([
273
+ { type: "video", uri: url },
274
+ { type: "text", text: "Summarize this video." }
275
+ ])
276
+ # Equivalent convenience call:
277
+ gemini.youtube(url, prompt: "Summarize this video.")
278
+ ```
279
+
280
+ Invalid URLs/prompts return an error envelope without a network request. No video
281
+ is downloaded locally. Gemini controls video support, limits, and processing.
282
+
283
+ For streaming, pass the video and text blocks directly to `chat_stream`; the
284
+ `youtube` helper itself uses non-streaming `chat`.
285
+
286
+ ### Gemini streaming
287
+
288
+ ```ruby
289
+ result = gemini.chat_stream("Tell me a short story", debug: true) do |text|
290
+ print text
291
+ $stdout.flush
292
+ end
293
+ puts
294
+ warn result["error"] if result["error"]
295
+
296
+ # Class-level calls also work:
297
+ AiLite::Gemini.chat_stream("Hello") { |text| print text }
298
+ ```
299
+
300
+ `chat_stream` accepts the same input and keywords as `chat` and requires a block.
301
+ It yields model-output text fragments as they arrive, excluding reasoning and
302
+ other non-text events, and returns the standard envelope with the accumulated
303
+ answer and interaction ID. JSON-looking final text is parsed just like `chat`.
304
+ It runs in the calling thread without creating background threads.
305
+
306
+ Gemini's final streaming event may contain only partial interaction metadata.
307
+ With `debug: true`, `raw` therefore contains `{ "interaction" => ..., "events" => [...] }`:
308
+ the latest lifecycle interaction object and every received event, including tool,
309
+ reasoning, and usage events when provided. This differs from non-streaming `chat`,
310
+ whose `raw` is the complete HTTP response. No extra retrieval request is made;
311
+ retaining all events uses additional memory. Without debugging, `raw` is `nil`.
312
+
313
+ HTTP errors, malformed/disconnected streams, and incomplete or failed interactions
314
+ return an error envelope, retaining partial text when available. Always check
315
+ `error`, even with HTTP status `200`. Missing blocks raise `ArgumentError`, and
316
+ exceptions from your block propagate. The configured timeout applies to each
317
+ network read, not the total stream duration. See Google's
318
+ [streaming event reference](https://ai.google.dev/api/interactions-api#InteractionSseStreamEvent).
319
+
320
+ The remaining usage sections below describe the OpenAI provider.
321
+
27
322
  ## Usage
28
323
 
29
324
  ```ruby
30
325
  require "ai_lite"
31
326
 
32
- ai = AiLite.new
327
+ ai = AiLite::OpenAI.new
33
328
  result = ai.chat("Say hello")
34
329
 
35
330
  puts result["content"]
@@ -40,7 +335,7 @@ By default, the client looks for an API key in `OPENAI_API_KEY`, then falls back
40
335
  You can also pass the key directly:
41
336
 
42
337
  ```ruby
43
- ai = AiLite.new(api_key: "sk-...")
338
+ ai = AiLite::OpenAI.new(api_key: "sk-...")
44
339
  ```
45
340
 
46
341
  ## Configuration
@@ -49,7 +344,7 @@ In Rails, configure the default client from an initializer:
49
344
 
50
345
  ```ruby
51
346
  # config/initializers/ai_lite.rb
52
- AiLite.configure do |config|
347
+ AiLite::OpenAI.configure do |config|
53
348
  config.api_key = ENV["OPENAI_API_KEY"]
54
349
  config.model = "gpt-5.5"
55
350
  config.moderation_model = "omni-moderation-latest"
@@ -80,16 +375,47 @@ Any value left unset falls back to AI Lite's default:
80
375
  Then use the configured singleton-style client:
81
376
 
82
377
  ```ruby
83
- result = AiLite.chat("Say hello")
378
+ result = AiLite::OpenAI.chat("Say hello")
84
379
  ```
85
380
 
86
381
  You can still instantiate a separate client for another token:
87
382
 
88
383
  ```ruby
89
- client = AiLite.new(api_key: "sk-other-token")
384
+ client = AiLite::OpenAI.new(api_key: "sk-other-token")
90
385
  result = client.chat("Say hello")
91
386
  ```
92
387
 
388
+ ## Migrating to provider namespaces
389
+
390
+ Use `AiLite::OpenAI` for OpenAI configuration, class methods, and instances.
391
+ OpenAI owns its API URLs, defaults, configuration, HTTP requests, and response handling.
392
+ Gemini has its own configuration, requests, and response handling; provider capabilities are implemented separately.
393
+
394
+ Existing `AiLite.new`, `AiLite.chat`, and the other top-level methods continue to
395
+ forward to OpenAI, sharing the same configuration and cached client. They emit a
396
+ deprecation warning to stderr once per legacy entry point per process. They will
397
+ remain available throughout v1.x and will be removed in v2.0.
398
+
399
+ ```ruby
400
+ # Before
401
+ AiLite.configure { |config| config.api_key = ENV["OPENAI_API_KEY"] }
402
+ ai = AiLite.new
403
+
404
+ # After
405
+ AiLite::OpenAI.configure { |config| config.api_key = ENV["OPENAI_API_KEY"] }
406
+ ai = AiLite::OpenAI.new
407
+ # Class methods work too: AiLite::OpenAI.chat("Hello")
408
+ ```
409
+
410
+ `AiLite.new` returns an `AiLite::OpenAI` instance. Compatibility covers documented
411
+ construction, configuration, utility methods, and constants; subclassing the legacy
412
+ `AiLite` class or relying on its exact instance class is not supported. Migrate such
413
+ code to `AiLite::OpenAI`.
414
+
415
+ Existing utility calls keep their methods and result envelopes. Legacy
416
+ constants such as `AiLite::DEFAULT_MODEL` and `AiLite::Configuration` remain
417
+ aliases for their OpenAI counterparts. The gem version stays at `AiLite::VERSION`.
418
+
93
419
  ## Chat
94
420
 
95
421
  ```ruby
@@ -147,6 +473,51 @@ puts follow_up["content"]
147
473
 
148
474
  Passing `previous_response_id` through `options` remains supported for backward compatibility.
149
475
 
476
+ ### Streaming Chat
477
+
478
+ Use `chat_stream` to receive text as it arrives. It accepts the same keywords as
479
+ `chat`, requires a block, and returns the usual result envelope when the stream
480
+ finishes.
481
+
482
+ ```ruby
483
+ ai = AiLite::OpenAI.new
484
+
485
+ result = ai.chat_stream("Explain Ruby blocks in three sentences.") do |text|
486
+ print text
487
+ $stdout.flush
488
+ end
489
+ puts
490
+
491
+ warn result["error"] if result["error"]
492
+ # result["content"] contains the final answer.
493
+ # result["response_id"] can be passed as previous_response_id on the next call.
494
+ ```
495
+
496
+ Class-level usage is also supported:
497
+
498
+ ```ruby
499
+ AiLite::OpenAI.chat_stream("Say hello") { |text| print text }
500
+ ```
501
+
502
+ The block receives text strings, which are not necessarily whole words or tokens.
503
+ JSON-looking final output is parsed in the returned `content`, just like `chat`;
504
+ the block still receives the original text fragments. With `debug: true`, `raw`
505
+ contains the terminal response object (or the available error/last event on failure),
506
+ not a history of every streaming event. Tool arguments, reasoning, and refusal
507
+ events are not yielded as text; the terminal response retains these outputs in
508
+ `raw` when debugging. See the [OpenAI streaming event reference](https://developers.openai.com/api/reference/resources/responses/streaming-events).
509
+
510
+ Always check `result["error"]`: failed, incomplete, malformed, or disconnected
511
+ streams return an error, retaining text already received when available. An HTTP
512
+ status of `200` alone does not mean generation completed successfully. Exceptions
513
+ raised by your block propagate to the caller; calling without a block raises
514
+ `ArgumentError` before making a request.
515
+
516
+ Streaming runs in the calling thread and creates no worker threads. In Rails,
517
+ forwarding chunks to a browser still requires an appropriate streaming response
518
+ or messaging mechanism in the application. The configured read timeout applies
519
+ to individual network reads, not to the total generation duration.
520
+
150
521
  ## Moderation
151
522
 
152
523
  Use `moderate` to classify user-submitted text or images for potentially harmful content before saving, publishing, or sending it into another AI call.
@@ -508,9 +879,75 @@ result["content"] # transcript text
508
879
  result["raw"] # full response body when available
509
880
  ```
510
881
 
882
+ ## Model Availability
883
+
884
+ These helpers are implemented inside the OpenAI provider. Instance and class-level
885
+ calls are supported; class-level network calls use the configured OpenAI client.
886
+
887
+ ```ruby
888
+ ai = AiLite::OpenAI.new
889
+
890
+ result = ai.available_models
891
+ if result["error"]
892
+ warn result["error"]
893
+ else
894
+ puts result["content"] # Array of exact model IDs
895
+ end
896
+
897
+ ai.model_available?("gpt-5.5") # true, false, or nil if the lookup failed
898
+ AiLite::OpenAI.available_models(debug: true) # raw includes the full model list
899
+ ```
900
+
901
+ `available_models` makes one authenticated `GET /v1/models` request and returns
902
+ the standard envelope. Empty model lists are valid. `model_available?` makes a
903
+ fresh list request and compares the exact ID, without prefix or alias matching.
904
+ It returns `nil` on a lookup error; use `available_models` or
905
+ `check_configured_models` when you need the error details. Empty or non-string
906
+ IDs raise `ArgumentError`. As with other network methods, constructing a client
907
+ without an API key raises `ArgumentError`.
908
+
909
+ Inspect configured models locally:
910
+
911
+ ```ruby
912
+ ai.configured_models
913
+ # => { "chat" => "gpt-5.5", "moderation" => "omni-moderation-latest",
914
+ # "embedding" => "text-embedding-3-small", "image" => "gpt-image-2",
915
+ # "speech" => "gpt-4o-mini-tts", "transcription" => "gpt-transcribe" }
916
+
917
+ AiLite::OpenAI.configured_models # No API key or network request required
918
+ ```
919
+
920
+ `configured_models` returns a plain hash of usage names to model IDs. An instance
921
+ reports its own settings, including constructor overrides; the class method
922
+ reports the current provider configuration. Neither includes credentials.
923
+
924
+ Check all configured models with a single list request:
925
+
926
+ ```ruby
927
+ result = ai.check_configured_models
928
+ result["content"]["chat"]
929
+ # => { "model" => "gpt-5.5", "usage" => "chat",
930
+ # "available" => true, "error" => nil } # if listed for this key
931
+
932
+ warn result["error"] if result["error"]
933
+ ```
934
+
935
+ Each usage has a report entry. A successful lookup returns `available: true` or
936
+ `false`; lookup failures return `available: nil`, an entry-level error, and the
937
+ same error in the outer envelope. `debug: true` includes the original list or
938
+ error response in `raw`. Network checks use the client's configured timeout and
939
+ do not cache results or run automatically during initialization.
940
+
941
+ **These are model-list availability checks only.** They do not run chat, image,
942
+ speech, embedding, moderation, or transcription generation requests and create
943
+ no generated output. A listed model does not guarantee endpoint compatibility,
944
+ sufficient quota, or permission for every operation. See the
945
+ [OpenAI List models reference](https://developers.openai.com/api/reference/resources/models/methods/list).
946
+
511
947
  ## Return Shape
512
948
 
513
- Methods return a hash envelope.
949
+ Generation methods, `available_models`, and `check_configured_models` return a hash envelope.
950
+ `configured_models` returns a plain hash; `model_available?` returns `true`, `false`, or `nil`.
514
951
 
515
952
  Text output:
516
953
 
@@ -567,5 +1004,5 @@ result = ai.chat("Say hello", debug: true)
567
1004
  Run the test suite:
568
1005
 
569
1006
  ```sh
570
- ruby -Ilib:test test/ai_lite_test.rb
1007
+ ruby -Ilib:test test/run.rb
571
1008
  ```
@@ -0,0 +1,78 @@
1
+ class AiLite
2
+ class Gemini
3
+ module Chat
4
+ def chat(message, model: nil, instructions: nil, previous_response_id: nil, max_output_tokens: nil, debug: false, options: {})
5
+ response = nil
6
+ raw = nil
7
+ response_id = nil
8
+ payload = chat_payload(message, model: model, instructions: instructions,
9
+ previous_response_id: previous_response_id,
10
+ max_output_tokens: max_output_tokens, options: options)
11
+ response = post_interaction(payload)
12
+ raw = response.body
13
+ raw = JSON.parse(raw)
14
+ raise "Invalid Gemini response: expected an object" unless raw.is_a?(Hash)
15
+
16
+ response_id = raw["id"]
17
+ unless response.code.to_i.between?(200, 299)
18
+ return result_envelope(status: response.code.to_i, error: error_message(raw),
19
+ response_id: response_id, raw: raw, debug: debug)
20
+ end
21
+
22
+ error = if raw["error"]
23
+ error_message(raw)
24
+ elsif raw["status"] != "completed"
25
+ "Gemini interaction #{raw['status'] || 'has no status'}; inspect raw with debug: true"
26
+ end
27
+ steps = raw["steps"]
28
+ raise "Invalid Gemini response: expected steps" unless steps.is_a?(Array) || error
29
+
30
+ text = Array(steps).flat_map do |step|
31
+ next [] unless step.is_a?(Hash) && step["type"] == "model_output"
32
+
33
+ Array(step["content"]).map do |part|
34
+ part["text"] if part.is_a?(Hash) && part["type"] == "text" && part["text"].is_a?(String)
35
+ end.compact
36
+ end.join.strip
37
+ content = if text.empty?
38
+ nil
39
+ else
40
+ begin
41
+ JSON.parse(text)
42
+ rescue JSON::ParserError
43
+ text
44
+ end
45
+ end
46
+ result_envelope(status: response.code.to_i, content: content, error: error,
47
+ response_id: response_id, raw: raw, debug: debug)
48
+ rescue StandardError => e
49
+ result_envelope(status: response ? response.code.to_i : "unknown", error: e.message,
50
+ response_id: response_id, raw: raw, debug: debug)
51
+ end
52
+ private
53
+
54
+ def chat_payload(message, model:, instructions:, previous_response_id:, max_output_tokens:, options:, stream: false)
55
+ payload = options.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
56
+ if (!stream && payload["stream"]) || payload["background"]
57
+ raise ArgumentError, "Use chat_stream for streaming; background execution is not supported"
58
+ end
59
+ unless message.is_a?(String) || message.is_a?(Hash) || message.is_a?(Array)
60
+ raise ArgumentError, "message must be a string, a Gemini content hash, or an array of Gemini content/steps"
61
+ end
62
+ generation = payload.fetch("generation_config", {})
63
+ raise ArgumentError, "generation_config must be a hash" unless generation.is_a?(Hash)
64
+
65
+ generation = generation.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
66
+ generation["max_output_tokens"] = max_output_tokens || generation["max_output_tokens"] || self.max_output_tokens
67
+ payload.merge!("model" => model || self.model, "input" => message, "generation_config" => generation)
68
+ payload["system_instruction"] = instructions if instructions
69
+ payload["previous_interaction_id"] = previous_response_id if previous_response_id
70
+ payload["stream"] = true if stream
71
+ payload
72
+ end
73
+ end
74
+
75
+ include Chat
76
+ private_constant :Chat
77
+ end
78
+ end