ruby-openai 7.3.1 → 7.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # Ruby OpenAI
2
-
3
2
  [![Gem Version](https://img.shields.io/gem/v/ruby-openai.svg)](https://rubygems.org/gems/ruby-openai)
4
3
  [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.txt)
5
4
  [![CircleCI Build Status](https://circleci.com/gh/alexrudall/ruby-openai.svg?style=shield)](https://circleci.com/gh/alexrudall/ruby-openai)
6
5
 
7
6
  Use the [OpenAI API](https://openai.com/blog/openai-api/) with Ruby! 🤖❤️
8
7
 
9
- Stream text with GPT-4o, transcribe and translate audio with Whisper, or create images with DALL·E...
8
+ Stream text with GPT-4, transcribe and translate audio with Whisper, or create images with DALL·E...
9
+
10
+ 💥 Click [subscribe now](https://mailchi.mp/8c7b574726a9/ruby-openai) to hear first about new releases in the Rails AI newsletter!
10
11
 
11
- [📚 Rails AI (FREE Book)](https://railsai.com) | [🎮 Ruby AI Builders Discord](https://discord.gg/k4Uc224xVD) | [🐦 X](https://x.com/alexrudall) | [🧠 Anthropic Gem](https://github.com/alexrudall/anthropic) | [🚂 Midjourney Gem](https://github.com/alexrudall/midjourney)
12
+ [![RailsAI Newsletter](https://github.com/user-attachments/assets/737cbb99-6029-42b8-9f22-a106725a4b1f)](https://mailchi.mp/8c7b574726a9/ruby-openai)
13
+
14
+ [🎮 Ruby AI Builders Discord](https://discord.gg/k4Uc224xVD) | [🐦 X](https://x.com/alexrudall) | [🧠 Anthropic Gem](https://github.com/alexrudall/anthropic) | [🚂 Midjourney Gem](https://github.com/alexrudall/midjourney)
12
15
 
13
16
  ## Contents
14
17
 
@@ -17,7 +20,7 @@ Stream text with GPT-4o, transcribe and translate audio with Whisper, or create
17
20
  - [Installation](#installation)
18
21
  - [Bundler](#bundler)
19
22
  - [Gem install](#gem-install)
20
- - [Usage](#usage)
23
+ - [How to use](#how-to-use)
21
24
  - [Quickstart](#quickstart)
22
25
  - [With Config](#with-config)
23
26
  - [Custom timeout or base URI](#custom-timeout-or-base-uri)
@@ -49,6 +52,7 @@ Stream text with GPT-4o, transcribe and translate audio with Whisper, or create
49
52
  - [Threads and Messages](#threads-and-messages)
50
53
  - [Runs](#runs)
51
54
  - [Create and Run](#create-and-run)
55
+ - [Vision in a thread](#vision-in-a-thread)
52
56
  - [Runs involving function tools](#runs-involving-function-tools)
53
57
  - [Exploring chunks used in File Search](#exploring-chunks-used-in-file-search)
54
58
  - [Image Generation](#image-generation)
@@ -61,6 +65,7 @@ Stream text with GPT-4o, transcribe and translate audio with Whisper, or create
61
65
  - [Translate](#translate)
62
66
  - [Transcribe](#transcribe)
63
67
  - [Speech](#speech)
68
+ - [Usage](#usage)
64
69
  - [Errors](#errors-1)
65
70
  - [Development](#development)
66
71
  - [Release](#release)
@@ -98,7 +103,7 @@ and require with:
98
103
  require "openai"
99
104
  ```
100
105
 
101
- ## Usage
106
+ ## How to use
102
107
 
103
108
  - Get your API key from [https://platform.openai.com/account/api-keys](https://platform.openai.com/account/api-keys)
104
109
  - If you belong to multiple organizations, you can get your Organization ID from [https://platform.openai.com/account/org-settings](https://platform.openai.com/account/org-settings)
@@ -121,6 +126,7 @@ For a more robust setup, you can configure the gem with your API keys, for examp
121
126
  ```ruby
122
127
  OpenAI.configure do |config|
123
128
  config.access_token = ENV.fetch("OPENAI_ACCESS_TOKEN")
129
+ config.admin_token = ENV.fetch("OPENAI_ADMIN_TOKEN") # Optional, used for admin endpoints, created here: https://platform.openai.com/settings/organization/admin-keys
124
130
  config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID") # Optional
125
131
  config.log_errors = true # Highly recommended in development, so you can see what errors OpenAI is returning. Not recommended in production because it could leak private data to your logs.
126
132
  end
@@ -132,10 +138,10 @@ Then you can create a client like this:
132
138
  client = OpenAI::Client.new
133
139
  ```
134
140
 
135
- You can still override the config defaults when making new clients; any options not included will fall back to any global config set with OpenAI.configure. e.g. in this example the organization_id, request_timeout, etc. will fallback to any set globally using OpenAI.configure, with only the access_token overridden:
141
+ You can still override the config defaults when making new clients; any options not included will fall back to any global config set with OpenAI.configure. e.g. in this example the organization_id, request_timeout, etc. will fallback to any set globally using OpenAI.configure, with only the access_token and admin_token overridden:
136
142
 
137
143
  ```ruby
138
- client = OpenAI::Client.new(access_token: "access_token_goes_here")
144
+ client = OpenAI::Client.new(access_token: "access_token_goes_here", admin_token: "admin_token_goes_here")
139
145
  ```
140
146
 
141
147
  #### Custom timeout or base URI
@@ -146,15 +152,15 @@ client = OpenAI::Client.new(access_token: "access_token_goes_here")
146
152
 
147
153
  ```ruby
148
154
  client = OpenAI::Client.new(
149
- access_token: "access_token_goes_here",
150
- uri_base: "https://oai.hconeai.com/",
151
- request_timeout: 240,
152
- extra_headers: {
153
- "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
154
- "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
155
- "Helicone-Auth": "Bearer HELICONE_API_KEY", # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
156
- "helicone-stream-force-format" => "true", # Use this with Helicone otherwise streaming drops chunks # https://github.com/alexrudall/ruby-openai/issues/251
157
- }
155
+ access_token: "access_token_goes_here",
156
+ uri_base: "https://oai.hconeai.com/",
157
+ request_timeout: 240,
158
+ extra_headers: {
159
+ "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
160
+ "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
161
+ "Helicone-Auth": "Bearer HELICONE_API_KEY", # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
162
+ "helicone-stream-force-format" => "true", # Use this with Helicone otherwise streaming drops chunks # https://github.com/alexrudall/ruby-openai/issues/251
163
+ }
158
164
  )
159
165
  ```
160
166
 
@@ -162,16 +168,17 @@ or when configuring the gem:
162
168
 
163
169
  ```ruby
164
170
  OpenAI.configure do |config|
165
- config.access_token = ENV.fetch("OPENAI_ACCESS_TOKEN")
166
- config.log_errors = true # Optional
167
- config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID") # Optional
168
- config.uri_base = "https://oai.hconeai.com/" # Optional
169
- config.request_timeout = 240 # Optional
170
- config.extra_headers = {
171
- "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
172
- "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
173
- "Helicone-Auth": "Bearer HELICONE_API_KEY" # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
174
- } # Optional
171
+ config.access_token = ENV.fetch("OPENAI_ACCESS_TOKEN")
172
+ config.admin_token = ENV.fetch("OPENAI_ADMIN_TOKEN") # Optional, used for admin endpoints, created here: https://platform.openai.com/settings/organization/admin-keys
173
+ config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID") # Optional
174
+ config.log_errors = true # Optional
175
+ config.uri_base = "https://oai.hconeai.com/" # Optional
176
+ config.request_timeout = 240 # Optional
177
+ config.extra_headers = {
178
+ "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
179
+ "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
180
+ "Helicone-Auth": "Bearer HELICONE_API_KEY" # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
181
+ } # Optional
175
182
  end
176
183
  ```
177
184
 
@@ -193,7 +200,7 @@ By default, `ruby-openai` does not log any `Faraday::Error`s encountered while e
193
200
  If you would like to enable this functionality, you can set `log_errors` to `true` when configuring the client:
194
201
 
195
202
  ```ruby
196
- client = OpenAI::Client.new(log_errors: true)
203
+ client = OpenAI::Client.new(log_errors: true)
197
204
  ```
198
205
 
199
206
  ##### Faraday middleware
@@ -201,9 +208,9 @@ If you would like to enable this functionality, you can set `log_errors` to `tru
201
208
  You can pass [Faraday middleware](https://lostisland.github.io/faraday/#/middleware/index) to the client in a block, eg. to enable verbose logging with Ruby's [Logger](https://ruby-doc.org/3.2.2/stdlibs/logger/Logger.html):
202
209
 
203
210
  ```ruby
204
- client = OpenAI::Client.new do |f|
205
- f.response :logger, Logger.new($stdout), bodies: true
206
- end
211
+ client = OpenAI::Client.new do |f|
212
+ f.response :logger, Logger.new($stdout), bodies: true
213
+ end
207
214
  ```
208
215
 
209
216
  #### Azure
@@ -211,12 +218,12 @@ You can pass [Faraday middleware](https://lostisland.github.io/faraday/#/middlew
211
218
  To use the [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/cognitive-services/openai/) API, you can configure the gem like this:
212
219
 
213
220
  ```ruby
214
- OpenAI.configure do |config|
215
- config.access_token = ENV.fetch("AZURE_OPENAI_API_KEY")
216
- config.uri_base = ENV.fetch("AZURE_OPENAI_URI")
217
- config.api_type = :azure
218
- config.api_version = "2023-03-15-preview"
219
- end
221
+ OpenAI.configure do |config|
222
+ config.access_token = ENV.fetch("AZURE_OPENAI_API_KEY")
223
+ config.uri_base = ENV.fetch("AZURE_OPENAI_URI")
224
+ config.api_type = :azure
225
+ config.api_version = "2023-03-15-preview"
226
+ end
220
227
  ```
221
228
 
222
229
  where `AZURE_OPENAI_URI` is e.g. `https://custom-domain.openai.azure.com/openai/deployments/gpt-35-turbo`
@@ -241,14 +248,15 @@ client = OpenAI::Client.new(
241
248
  )
242
249
 
243
250
  client.chat(
244
- parameters: {
245
- model: "llama3", # Required.
246
- messages: [{ role: "user", content: "Hello!"}], # Required.
247
- temperature: 0.7,
248
- stream: proc do |chunk, _bytesize|
249
- print chunk.dig("choices", 0, "delta", "content")
250
- end
251
- })
251
+ parameters: {
252
+ model: "llama3", # Required.
253
+ messages: [{ role: "user", content: "Hello!"}], # Required.
254
+ temperature: 0.7,
255
+ stream: proc do |chunk, _bytesize|
256
+ print chunk.dig("choices", 0, "delta", "content")
257
+ end
258
+ }
259
+ )
252
260
 
253
261
  # => Hi! It's nice to meet you. Is there something I can help you with, or would you like to chat?
254
262
  ```
@@ -258,20 +266,21 @@ client.chat(
258
266
  [Groq API Chat](https://console.groq.com/docs/quickstart) is broadly compatible with the OpenAI API, with a [few minor differences](https://console.groq.com/docs/openai). Get an access token from [here](https://console.groq.com/keys), then:
259
267
 
260
268
  ```ruby
261
- client = OpenAI::Client.new(
262
- access_token: "groq_access_token_goes_here",
263
- uri_base: "https://api.groq.com/openai"
264
- )
269
+ client = OpenAI::Client.new(
270
+ access_token: "groq_access_token_goes_here",
271
+ uri_base: "https://api.groq.com/openai"
272
+ )
265
273
 
266
- client.chat(
267
- parameters: {
268
- model: "llama3-8b-8192", # Required.
269
- messages: [{ role: "user", content: "Hello!"}], # Required.
270
- temperature: 0.7,
271
- stream: proc do |chunk, _bytesize|
272
- print chunk.dig("choices", 0, "delta", "content")
273
- end
274
- })
274
+ client.chat(
275
+ parameters: {
276
+ model: "llama3-8b-8192", # Required.
277
+ messages: [{ role: "user", content: "Hello!"}], # Required.
278
+ temperature: 0.7,
279
+ stream: proc do |chunk, _bytesize|
280
+ print chunk.dig("choices", 0, "delta", "content")
281
+ end
282
+ }
283
+ )
275
284
  ```
276
285
 
277
286
  ### Counting Tokens
@@ -301,11 +310,12 @@ GPT is a model that can be used to generate text in a conversational style. You
301
310
 
302
311
  ```ruby
303
312
  response = client.chat(
304
- parameters: {
305
- model: "gpt-4o", # Required.
306
- messages: [{ role: "user", content: "Hello!"}], # Required.
307
- temperature: 0.7,
308
- })
313
+ parameters: {
314
+ model: "gpt-4o", # Required.
315
+ messages: [{ role: "user", content: "Hello!"}], # Required.
316
+ temperature: 0.7,
317
+ }
318
+ )
309
319
  puts response.dig("choices", 0, "message", "content")
310
320
  # => "Hello! How may I assist you today?"
311
321
  ```
@@ -318,14 +328,15 @@ You can stream from the API in realtime, which can be much faster and used to cr
318
328
 
319
329
  ```ruby
320
330
  client.chat(
321
- parameters: {
322
- model: "gpt-4o", # Required.
323
- messages: [{ role: "user", content: "Describe a character called Anna!"}], # Required.
324
- temperature: 0.7,
325
- stream: proc do |chunk, _bytesize|
326
- print chunk.dig("choices", 0, "delta", "content")
327
- end
328
- })
331
+ parameters: {
332
+ model: "gpt-4o", # Required.
333
+ messages: [{ role: "user", content: "Describe a character called Anna!"}], # Required.
334
+ temperature: 0.7,
335
+ stream: proc do |chunk, _bytesize|
336
+ print chunk.dig("choices", 0, "delta", "content")
337
+ end
338
+ }
339
+ )
329
340
  # => "Anna is a young woman in her mid-twenties, with wavy chestnut hair that falls to her shoulders..."
330
341
  ```
331
342
 
@@ -334,12 +345,13 @@ Note: In order to get usage information, you can provide the [`stream_options` p
334
345
  ```ruby
335
346
  stream_proc = proc { |chunk, _bytesize| puts "--------------"; puts chunk.inspect; }
336
347
  client.chat(
337
- parameters: {
338
- model: "gpt-4o",
339
- stream: stream_proc,
340
- stream_options: { include_usage: true },
341
- messages: [{ role: "user", content: "Hello!"}],
342
- })
348
+ parameters: {
349
+ model: "gpt-4o",
350
+ stream: stream_proc,
351
+ stream_options: { include_usage: true },
352
+ messages: [{ role: "user", content: "Hello!"}],
353
+ }
354
+ )
343
355
  # => --------------
344
356
  # => {"id"=>"chatcmpl-7bbq05PiZqlHxjV1j7OHnKKDURKaf", "object"=>"chat.completion.chunk", "created"=>1718750612, "model"=>"gpt-4o-2024-05-13", "system_fingerprint"=>"fp_9cb5d38cf7", "choices"=>[{"index"=>0, "delta"=>{"role"=>"assistant", "content"=>""}, "logprobs"=>nil, "finish_reason"=>nil}], "usage"=>nil}
345
357
  # => --------------
@@ -366,10 +378,11 @@ messages = [
366
378
  }
367
379
  ]
368
380
  response = client.chat(
369
- parameters: {
370
- model: "gpt-4-vision-preview", # Required.
371
- messages: [{ role: "user", content: messages}], # Required.
372
- })
381
+ parameters: {
382
+ model: "gpt-4-vision-preview", # Required.
383
+ messages: [{ role: "user", content: messages}], # Required.
384
+ }
385
+ )
373
386
  puts response.dig("choices", 0, "message", "content")
374
387
  # => "The image depicts a serene natural landscape featuring a long wooden boardwalk extending straight ahead"
375
388
  ```
@@ -379,21 +392,22 @@ puts response.dig("choices", 0, "message", "content")
379
392
  You can set the response_format to ask for responses in JSON:
380
393
 
381
394
  ```ruby
382
- response = client.chat(
383
- parameters: {
384
- model: "gpt-4o",
385
- response_format: { type: "json_object" },
386
- messages: [{ role: "user", content: "Hello! Give me some JSON please."}],
387
- temperature: 0.7,
388
- })
389
- puts response.dig("choices", 0, "message", "content")
390
- {
391
- "name": "John",
392
- "age": 30,
393
- "city": "New York",
394
- "hobbies": ["reading", "traveling", "hiking"],
395
- "isStudent": false
396
- }
395
+ response = client.chat(
396
+ parameters: {
397
+ model: "gpt-4o",
398
+ response_format: { type: "json_object" },
399
+ messages: [{ role: "user", content: "Hello! Give me some JSON please."}],
400
+ temperature: 0.7,
401
+ })
402
+ puts response.dig("choices", 0, "message", "content")
403
+ # =>
404
+ # {
405
+ # "name": "John",
406
+ # "age": 30,
407
+ # "city": "New York",
408
+ # "hobbies": ["reading", "traveling", "hiking"],
409
+ # "isStudent": false
410
+ # }
397
411
  ```
398
412
 
399
413
  You can stream it as well!
@@ -403,26 +417,28 @@ You can stream it as well!
403
417
  parameters: {
404
418
  model: "gpt-4o",
405
419
  messages: [{ role: "user", content: "Can I have some JSON please?"}],
406
- response_format: { type: "json_object" },
407
- stream: proc do |chunk, _bytesize|
408
- print chunk.dig("choices", 0, "delta", "content")
409
- end
410
- })
411
- {
412
- "message": "Sure, please let me know what specific JSON data you are looking for.",
413
- "JSON_data": {
414
- "example_1": {
415
- "key_1": "value_1",
416
- "key_2": "value_2",
417
- "key_3": "value_3"
418
- },
419
- "example_2": {
420
- "key_4": "value_4",
421
- "key_5": "value_5",
422
- "key_6": "value_6"
423
- }
420
+ response_format: { type: "json_object" },
421
+ stream: proc do |chunk, _bytesize|
422
+ print chunk.dig("choices", 0, "delta", "content")
423
+ end
424
424
  }
425
- }
425
+ )
426
+ # =>
427
+ # {
428
+ # "message": "Sure, please let me know what specific JSON data you are looking for.",
429
+ # "JSON_data": {
430
+ # "example_1": {
431
+ # "key_1": "value_1",
432
+ # "key_2": "value_2",
433
+ # "key_3": "value_3"
434
+ # },
435
+ # "example_2": {
436
+ # "key_4": "value_4",
437
+ # "key_5": "value_5",
438
+ # "key_6": "value_6"
439
+ # }
440
+ # }
441
+ # }
426
442
  ```
427
443
 
428
444
  ### Functions
@@ -430,7 +446,6 @@ You can stream it as well!
430
446
  You can describe and pass in functions and the model will intelligently choose to output a JSON object containing arguments to call them - eg., to use your method `get_current_weather` to get the weather in a given location. Note that tool_choice is optional, but if you exclude it, the model will choose whether to use the function or not ([see here](https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice)).
431
447
 
432
448
  ```ruby
433
-
434
449
  def get_current_weather(location:, unit: "fahrenheit")
435
450
  # Here you could use a weather api to fetch the weather.
436
451
  "The weather in #{location} is nice 🌞 #{unit}"
@@ -471,8 +486,9 @@ response =
471
486
  },
472
487
  }
473
488
  ],
474
- tool_choice: "required" # Optional, defaults to "auto"
475
- # Can also put "none" or specific functions, see docs
489
+ # Optional, defaults to "auto"
490
+ # Can also put "none" or specific functions, see docs
491
+ tool_choice: "required"
476
492
  },
477
493
  )
478
494
 
@@ -486,12 +502,13 @@ if message["role"] == "assistant" && message["tool_calls"]
486
502
  tool_call.dig("function", "arguments"),
487
503
  { symbolize_names: true },
488
504
  )
489
- function_response = case function_name
505
+ function_response =
506
+ case function_name
490
507
  when "get_current_weather"
491
508
  get_current_weather(**function_args) # => "The weather is nice 🌞"
492
509
  else
493
510
  # decide how to handle
494
- end
511
+ end
495
512
 
496
513
  # For a subsequent message with the role "tool", OpenAI requires the preceding message to have a tool_calls argument.
497
514
  messages << message
@@ -508,7 +525,8 @@ if message["role"] == "assistant" && message["tool_calls"]
508
525
  parameters: {
509
526
  model: "gpt-4o",
510
527
  messages: messages
511
- })
528
+ }
529
+ )
512
530
 
513
531
  puts second_response.dig("choices", 0, "message", "content")
514
532
 
@@ -524,11 +542,12 @@ Hit the OpenAI API for a completion using other GPT-3 models:
524
542
 
525
543
  ```ruby
526
544
  response = client.completions(
527
- parameters: {
528
- model: "gpt-4o",
529
- prompt: "Once upon a time",
530
- max_tokens: 5
531
- })
545
+ parameters: {
546
+ model: "gpt-4o",
547
+ prompt: "Once upon a time",
548
+ max_tokens: 5
549
+ }
550
+ )
532
551
  puts response["choices"].map { |c| c["text"] }
533
552
  # => [", there lived a great"]
534
553
  ```
@@ -539,10 +558,10 @@ You can use the embeddings endpoint to get a vector of numbers representing an i
539
558
 
540
559
  ```ruby
541
560
  response = client.embeddings(
542
- parameters: {
543
- model: "text-embedding-ada-002",
544
- input: "The food was delicious and the waiter..."
545
- }
561
+ parameters: {
562
+ model: "text-embedding-ada-002",
563
+ input: "The food was delicious and the waiter..."
564
+ }
546
565
  )
547
566
 
548
567
  puts response.dig("data", 0, "embedding")
@@ -688,9 +707,9 @@ You can then use this file ID to create a fine tuning job:
688
707
 
689
708
  ```ruby
690
709
  response = client.finetunes.create(
691
- parameters: {
692
- training_file: file_id,
693
- model: "gpt-4o"
710
+ parameters: {
711
+ training_file: file_id,
712
+ model: "gpt-4o"
694
713
  })
695
714
  fine_tune_id = response["id"]
696
715
  ```
@@ -713,17 +732,17 @@ This fine-tuned model name can then be used in chat completions:
713
732
 
714
733
  ```ruby
715
734
  response = client.chat(
716
- parameters: {
717
- model: fine_tuned_model,
718
- messages: [{ role: "user", content: "I love Mondays!"}]
719
- }
735
+ parameters: {
736
+ model: fine_tuned_model,
737
+ messages: [{ role: "user", content: "I love Mondays!" }]
738
+ }
720
739
  )
721
740
  response.dig("choices", 0, "message", "content")
722
741
  ```
723
742
 
724
743
  You can also capture the events for a job:
725
744
 
726
- ```
745
+ ```ruby
727
746
  client.finetunes.list_events(id: fine_tune_id)
728
747
  ```
729
748
 
@@ -868,25 +887,26 @@ To create a new assistant:
868
887
 
869
888
  ```ruby
870
889
  response = client.assistants.create(
871
- parameters: {
872
- model: "gpt-4o",
873
- name: "OpenAI-Ruby test assistant",
874
- description: nil,
875
- instructions: "You are a Ruby dev bot. When asked a question, write and run Ruby code to answer the question",
876
- tools: [
877
- { type: "code_interpreter" },
878
- { type: "file_search" }
879
- ],
880
- tool_resources: {
881
- code_interpreter: {
882
- file_ids: [] # See Files section above for how to upload files
883
- },
884
- file_search: {
885
- vector_store_ids: [] # See Vector Stores section above for how to add vector stores
886
- }
887
- },
888
- "metadata": { my_internal_version_id: "1.0.0" }
889
- })
890
+ parameters: {
891
+ model: "gpt-4o",
892
+ name: "OpenAI-Ruby test assistant",
893
+ description: nil,
894
+ instructions: "You are a Ruby dev bot. When asked a question, write and run Ruby code to answer the question",
895
+ tools: [
896
+ { type: "code_interpreter" },
897
+ { type: "file_search" }
898
+ ],
899
+ tool_resources: {
900
+ code_interpreter: {
901
+ file_ids: [] # See Files section above for how to upload files
902
+ },
903
+ file_search: {
904
+ vector_store_ids: [] # See Vector Stores section above for how to add vector stores
905
+ }
906
+ },
907
+ "metadata": { my_internal_version_id: "1.0.0" }
908
+ }
909
+ )
890
910
  assistant_id = response["id"]
891
911
  ```
892
912
 
@@ -906,16 +926,17 @@ You can modify an existing assistant using the assistant's id (see [API document
906
926
 
907
927
  ```ruby
908
928
  response = client.assistants.modify(
909
- id: assistant_id,
910
- parameters: {
911
- name: "Modified Test Assistant for OpenAI-Ruby",
912
- metadata: { my_internal_version_id: '1.0.1' }
913
- })
929
+ id: assistant_id,
930
+ parameters: {
931
+ name: "Modified Test Assistant for OpenAI-Ruby",
932
+ metadata: { my_internal_version_id: '1.0.1' }
933
+ }
934
+ )
914
935
  ```
915
936
 
916
937
  You can delete assistants:
917
938
 
918
- ```
939
+ ```ruby
919
940
  client.assistants.delete(id: assistant_id)
920
941
  ```
921
942
 
@@ -931,11 +952,12 @@ thread_id = response["id"]
931
952
 
932
953
  # Add initial message from user (see https://platform.openai.com/docs/api-reference/messages/createMessage)
933
954
  message_id = client.messages.create(
934
- thread_id: thread_id,
935
- parameters: {
936
- role: "user", # Required for manually created messages
937
- content: "Can you help me write an API library to interact with the OpenAI API please?"
938
- })["id"]
955
+ thread_id: thread_id,
956
+ parameters: {
957
+ role: "user", # Required for manually created messages
958
+ content: "Can you help me write an API library to interact with the OpenAI API please?"
959
+ }
960
+ )["id"]
939
961
 
940
962
  # Retrieve individual message
941
963
  message = client.messages.retrieve(thread_id: thread_id, id: message_id)
@@ -959,32 +981,38 @@ To submit a thread to be evaluated with the model of an assistant, create a `Run
959
981
 
960
982
  ```ruby
961
983
  # Create run (will use instruction/model/tools from Assistant's definition)
962
- response = client.runs.create(thread_id: thread_id,
963
- parameters: {
964
- assistant_id: assistant_id,
965
- max_prompt_tokens: 256,
966
- max_completion_tokens: 16
967
- })
984
+ response = client.runs.create(
985
+ thread_id: thread_id,
986
+ parameters: {
987
+ assistant_id: assistant_id,
988
+ max_prompt_tokens: 256,
989
+ max_completion_tokens: 16
990
+ }
991
+ )
968
992
  run_id = response['id']
969
993
  ```
970
994
 
971
995
  You can stream the message chunks as they come through:
972
996
 
973
997
  ```ruby
974
- client.runs.create(thread_id: thread_id,
975
- parameters: {
976
- assistant_id: assistant_id,
977
- max_prompt_tokens: 256,
978
- max_completion_tokens: 16,
979
- stream: proc do |chunk, _bytesize|
980
- print chunk.dig("delta", "content", 0, "text", "value") if chunk["object"] == "thread.message.delta"
981
- end
982
- })
998
+ client.runs.create(
999
+ thread_id: thread_id,
1000
+ parameters: {
1001
+ assistant_id: assistant_id,
1002
+ max_prompt_tokens: 256,
1003
+ max_completion_tokens: 16,
1004
+ stream: proc do |chunk, _bytesize|
1005
+ if chunk["object"] == "thread.message.delta"
1006
+ print chunk.dig("delta", "content", 0, "text", "value")
1007
+ end
1008
+ end
1009
+ }
1010
+ )
983
1011
  ```
984
1012
 
985
1013
  To get the status of a Run:
986
1014
 
987
- ```
1015
+ ```ruby
988
1016
  response = client.runs.retrieve(id: run_id, thread_id: thread_id)
989
1017
  status = response['status']
990
1018
  ```
@@ -993,23 +1021,23 @@ The `status` response can include the following strings `queued`, `in_progress`,
993
1021
 
994
1022
  ```ruby
995
1023
  while true do
996
- response = client.runs.retrieve(id: run_id, thread_id: thread_id)
997
- status = response['status']
998
-
999
- case status
1000
- when 'queued', 'in_progress', 'cancelling'
1001
- puts 'Sleeping'
1002
- sleep 1 # Wait one second and poll again
1003
- when 'completed'
1004
- break # Exit loop and report result to user
1005
- when 'requires_action'
1006
- # Handle tool calls (see below)
1007
- when 'cancelled', 'failed', 'expired'
1008
- puts response['last_error'].inspect
1009
- break # or `exit`
1010
- else
1011
- puts "Unknown status response: #{status}"
1012
- end
1024
+ response = client.runs.retrieve(id: run_id, thread_id: thread_id)
1025
+ status = response['status']
1026
+
1027
+ case status
1028
+ when 'queued', 'in_progress', 'cancelling'
1029
+ puts 'Sleeping'
1030
+ sleep 1 # Wait one second and poll again
1031
+ when 'completed'
1032
+ break # Exit loop and report result to user
1033
+ when 'requires_action'
1034
+ # Handle tool calls (see below)
1035
+ when 'cancelled', 'failed', 'expired'
1036
+ puts response['last_error'].inspect
1037
+ break # or `exit`
1038
+ else
1039
+ puts "Unknown status response: #{status}"
1040
+ end
1013
1041
  end
1014
1042
  ```
1015
1043
 
@@ -1021,30 +1049,30 @@ messages = client.messages.list(thread_id: thread_id, parameters: { order: 'asc'
1021
1049
 
1022
1050
  # Alternatively retrieve the `run steps` for the run which link to the messages:
1023
1051
  run_steps = client.run_steps.list(thread_id: thread_id, run_id: run_id, parameters: { order: 'asc' })
1024
- new_message_ids = run_steps['data'].filter_map { |step|
1052
+ new_message_ids = run_steps['data'].filter_map do |step|
1025
1053
  if step['type'] == 'message_creation'
1026
1054
  step.dig('step_details', "message_creation", "message_id")
1027
1055
  end # Ignore tool calls, because they don't create new messages.
1028
- }
1056
+ end
1029
1057
 
1030
1058
  # Retrieve the individual messages
1031
- new_messages = new_message_ids.map { |msg_id|
1059
+ new_messages = new_message_ids.map do |msg_id|
1032
1060
  client.messages.retrieve(id: msg_id, thread_id: thread_id)
1033
- }
1061
+ end
1034
1062
 
1035
1063
  # Find the actual response text in the content array of the messages
1036
- new_messages.each { |msg|
1037
- msg['content'].each { |content_item|
1038
- case content_item['type']
1039
- when 'text'
1040
- puts content_item.dig('text', 'value')
1041
- # Also handle annotations
1042
- when 'image_file'
1043
- # Use File endpoint to retrieve file contents via id
1044
- id = content_item.dig('image_file', 'file_id')
1045
- end
1046
- }
1047
- }
1064
+ new_messages.each do |msg|
1065
+ msg['content'].each do |content_item|
1066
+ case content_item['type']
1067
+ when 'text'
1068
+ puts content_item.dig('text', 'value')
1069
+ # Also handle annotations
1070
+ when 'image_file'
1071
+ # Use File endpoint to retrieve file contents via id
1072
+ id = content_item.dig('image_file', 'file_id')
1073
+ end
1074
+ end
1075
+ end
1048
1076
  ```
1049
1077
 
1050
1078
  You can also update the metadata on messages, including messages that come from the assistant.
@@ -1053,7 +1081,11 @@ You can also update the metadata on messages, including messages that come from
1053
1081
  metadata = {
1054
1082
  user_id: "abc123"
1055
1083
  }
1056
- message = client.messages.modify(id: message_id, thread_id: thread_id, parameters: { metadata: metadata })
1084
+ message = client.messages.modify(
1085
+ id: message_id,
1086
+ thread_id: thread_id,
1087
+ parameters: { metadata: metadata },
1088
+ )
1057
1089
  ```
1058
1090
 
1059
1091
  At any time you can list all runs which have been performed on a particular thread or are currently running:
@@ -1072,41 +1104,117 @@ run_id = response['id']
1072
1104
  thread_id = response['thread_id']
1073
1105
  ```
1074
1106
 
1107
+ #### Vision in a thread
1108
+
1109
+ You can include images in a thread and they will be described & read by the LLM. In this example I'm using [this file](https://upload.wikimedia.org/wikipedia/commons/7/70/Example.png):
1110
+
1111
+ ```ruby
1112
+ require "openai"
1113
+
1114
+ # Make a client
1115
+ client = OpenAI::Client.new(
1116
+ access_token: "access_token_goes_here",
1117
+ log_errors: true # Don't log errors in production.
1118
+ )
1119
+
1120
+ # Upload image as a file
1121
+ file_id = client.files.upload(
1122
+ parameters: {
1123
+ file: "path/to/example.png",
1124
+ purpose: "assistants",
1125
+ }
1126
+ )["id"]
1127
+
1128
+ # Create assistant (You could also use an existing one here)
1129
+ assistant_id = client.assistants.create(
1130
+ parameters: {
1131
+ model: "gpt-4o",
1132
+ name: "Image reader",
1133
+ instructions: "You are an image describer. You describe the contents of images.",
1134
+ }
1135
+ )["id"]
1136
+
1137
+ # Create thread
1138
+ thread_id = client.threads.create["id"]
1139
+
1140
+ # Add image in message
1141
+ client.messages.create(
1142
+ thread_id: thread_id,
1143
+ parameters: {
1144
+ role: "user", # Required for manually created messages
1145
+ content: [
1146
+ {
1147
+ "type": "text",
1148
+ "text": "What's in this image?"
1149
+ },
1150
+ {
1151
+ "type": "image_file",
1152
+ "image_file": { "file_id": file_id }
1153
+ }
1154
+ ]
1155
+ }
1156
+ )
1157
+
1158
+ # Run thread
1159
+ run_id = client.runs.create(
1160
+ thread_id: thread_id,
1161
+ parameters: { assistant_id: assistant_id }
1162
+ )["id"]
1163
+
1164
+ # Wait until run in complete
1165
+ status = nil
1166
+ until status == "completed" do
1167
+ sleep(0.1)
1168
+ status = client.runs.retrieve(id: run_id, thread_id: thread_id)['status']
1169
+ end
1170
+
1171
+ # Get the response
1172
+ messages = client.messages.list(thread_id: thread_id, parameters: { order: 'asc' })
1173
+ messages.dig("data", -1, "content", 0, "text", "value")
1174
+ => "The image contains a placeholder graphic with a tilted, stylized representation of a postage stamp in the top part, which includes an abstract landscape with hills and a sun. Below the stamp, in the middle of the image, there is italicized text in a light golden color that reads, \"This is just an example.\" The background is a light pastel shade, and a yellow border frames the entire image."
1175
+ ```
1176
+
1075
1177
  #### Runs involving function tools
1076
1178
 
1077
1179
  In case you are allowing the assistant to access `function` tools (they are defined in the same way as functions during chat completion), you might get a status code of `requires_action` when the assistant wants you to evaluate one or more function tools:
1078
1180
 
1079
1181
  ```ruby
1080
1182
  def get_current_weather(location:, unit: "celsius")
1081
- # Your function code goes here
1082
- if location =~ /San Francisco/i
1083
- return unit == "celsius" ? "The weather is nice 🌞 at 27°C" : "The weather is nice 🌞 at 80°F"
1084
- else
1085
- return unit == "celsius" ? "The weather is icy 🥶 at -5°C" : "The weather is icy 🥶 at 23°F"
1086
- end
1183
+ # Your function code goes here
1184
+ if location =~ /San Francisco/i
1185
+ return unit == "celsius" ? "The weather is nice 🌞 at 27°C" : "The weather is nice 🌞 at 80°F"
1186
+ else
1187
+ return unit == "celsius" ? "The weather is icy 🥶 at -5°C" : "The weather is icy 🥶 at 23°F"
1188
+ end
1087
1189
  end
1088
1190
 
1089
1191
  if status == 'requires_action'
1192
+ tools_to_call = response.dig('required_action', 'submit_tool_outputs', 'tool_calls')
1090
1193
 
1091
- tools_to_call = response.dig('required_action', 'submit_tool_outputs', 'tool_calls')
1092
-
1093
- my_tool_outputs = tools_to_call.map { |tool|
1094
- # Call the functions based on the tool's name
1095
- function_name = tool.dig('function', 'name')
1096
- arguments = JSON.parse(
1097
- tool.dig("function", "arguments"),
1098
- { symbolize_names: true },
1099
- )
1194
+ my_tool_outputs = tools_to_call.map { |tool|
1195
+ # Call the functions based on the tool's name
1196
+ function_name = tool.dig('function', 'name')
1197
+ arguments = JSON.parse(
1198
+ tool.dig("function", "arguments"),
1199
+ { symbolize_names: true },
1200
+ )
1100
1201
 
1101
- tool_output = case function_name
1102
- when "get_current_weather"
1103
- get_current_weather(**arguments)
1104
- end
1202
+ tool_output = case function_name
1203
+ when "get_current_weather"
1204
+ get_current_weather(**arguments)
1205
+ end
1105
1206
 
1106
- { tool_call_id: tool['id'], output: tool_output }
1207
+ {
1208
+ tool_call_id: tool['id'],
1209
+ output: tool_output,
1107
1210
  }
1211
+ }
1108
1212
 
1109
- client.runs.submit_tool_outputs(thread_id: thread_id, run_id: run_id, parameters: { tool_outputs: my_tool_outputs })
1213
+ client.runs.submit_tool_outputs(
1214
+ thread_id: thread_id,
1215
+ run_id: run_id,
1216
+ parameters: { tool_outputs: my_tool_outputs }
1217
+ )
1110
1218
  end
1111
1219
  ```
1112
1220
 
@@ -1122,13 +1230,13 @@ An example spec can be found [here](https://github.com/alexrudall/ruby-openai/bl
1122
1230
 
1123
1231
  Here's how to get the chunks used in a file search. In this example I'm using [this file](https://css4.pub/2015/textbook/somatosensory.pdf):
1124
1232
 
1125
- ```
1233
+ ```ruby
1126
1234
  require "openai"
1127
1235
 
1128
1236
  # Make a client
1129
1237
  client = OpenAI::Client.new(
1130
1238
  access_token: "access_token_goes_here",
1131
- log_errors: true # Don't do this in production.
1239
+ log_errors: true # Don't log errors in production.
1132
1240
  )
1133
1241
 
1134
1242
  # Upload your file(s)
@@ -1228,7 +1336,12 @@ Generate images using DALL·E 2 or DALL·E 3!
1228
1336
  For DALL·E 2 the size of any generated images must be one of `256x256`, `512x512` or `1024x1024` - if not specified the image will default to `1024x1024`.
1229
1337
 
1230
1338
  ```ruby
1231
- response = client.images.generate(parameters: { prompt: "A baby sea otter cooking pasta wearing a hat of some sort", size: "256x256" })
1339
+ response = client.images.generate(
1340
+ parameters: {
1341
+ prompt: "A baby sea otter cooking pasta wearing a hat of some sort",
1342
+ size: "256x256",
1343
+ }
1344
+ )
1232
1345
  puts response.dig("data", 0, "url")
1233
1346
  # => "https://oaidalleapiprodscus.blob.core.windows.net/private/org-Rf437IxKhh..."
1234
1347
  ```
@@ -1240,7 +1353,14 @@ puts response.dig("data", 0, "url")
1240
1353
  For DALL·E 3 the size of any generated images must be one of `1024x1024`, `1024x1792` or `1792x1024`. Additionally the quality of the image can be specified to either `standard` or `hd`.
1241
1354
 
1242
1355
  ```ruby
1243
- response = client.images.generate(parameters: { prompt: "A springer spaniel cooking pasta wearing a hat of some sort", model: "dall-e-3", size: "1024x1792", quality: "standard" })
1356
+ response = client.images.generate(
1357
+ parameters: {
1358
+ prompt: "A springer spaniel cooking pasta wearing a hat of some sort",
1359
+ model: "dall-e-3",
1360
+ size: "1024x1792",
1361
+ quality: "standard",
1362
+ }
1363
+ )
1244
1364
  puts response.dig("data", 0, "url")
1245
1365
  # => "https://oaidalleapiprodscus.blob.core.windows.net/private/org-Rf437IxKhh..."
1246
1366
  ```
@@ -1252,7 +1372,13 @@ puts response.dig("data", 0, "url")
1252
1372
  Fill in the transparent part of an image, or upload a mask with transparent sections to indicate the parts of an image that can be changed according to your prompt...
1253
1373
 
1254
1374
  ```ruby
1255
- response = client.images.edit(parameters: { prompt: "A solid red Ruby on a blue background", image: "image.png", mask: "mask.png" })
1375
+ response = client.images.edit(
1376
+ parameters: {
1377
+ prompt: "A solid red Ruby on a blue background",
1378
+ image: "image.png",
1379
+ mask: "mask.png",
1380
+ }
1381
+ )
1256
1382
  puts response.dig("data", 0, "url")
1257
1383
  # => "https://oaidalleapiprodscus.blob.core.windows.net/private/org-Rf437IxKhh..."
1258
1384
  ```
@@ -1292,10 +1418,11 @@ The translations API takes as input the audio file in any of the supported langu
1292
1418
 
1293
1419
  ```ruby
1294
1420
  response = client.audio.translate(
1295
- parameters: {
1296
- model: "whisper-1",
1297
- file: File.open("path_to_file", "rb"),
1298
- })
1421
+ parameters: {
1422
+ model: "whisper-1",
1423
+ file: File.open("path_to_file", "rb"),
1424
+ }
1425
+ )
1299
1426
  puts response["text"]
1300
1427
  # => "Translation of the text"
1301
1428
  ```
@@ -1308,11 +1435,12 @@ You can pass the language of the audio file to improve transcription quality. Su
1308
1435
 
1309
1436
  ```ruby
1310
1437
  response = client.audio.transcribe(
1311
- parameters: {
1312
- model: "whisper-1",
1313
- file: File.open("path_to_file", "rb"),
1314
- language: "en" # Optional
1315
- })
1438
+ parameters: {
1439
+ model: "whisper-1",
1440
+ file: File.open("path_to_file", "rb"),
1441
+ language: "en", # Optional
1442
+ }
1443
+ )
1316
1444
  puts response["text"]
1317
1445
  # => "Transcription of the text"
1318
1446
  ```
@@ -1328,23 +1456,81 @@ response = client.audio.speech(
1328
1456
  input: "This is a speech test!",
1329
1457
  voice: "alloy",
1330
1458
  response_format: "mp3", # Optional
1331
- speed: 1.0 # Optional
1459
+ speed: 1.0, # Optional
1332
1460
  }
1333
1461
  )
1334
1462
  File.binwrite('demo.mp3', response)
1335
1463
  # => mp3 file that plays: "This is a speech test!"
1336
1464
  ```
1337
1465
 
1338
- ### Errors
1466
+ ### Usage
1467
+ The Usage API provides information about the cost of various OpenAI services within your organization.
1468
+ To use Admin APIs like Usage, you need to set an OPENAI_ADMIN_TOKEN, which can be generated [here](https://platform.openai.com/settings/organization/admin-keys).
1339
1469
 
1340
- HTTP errors can be caught like this:
1470
+ ```ruby
1471
+ OpenAI.configure do |config|
1472
+ config.admin_token = ENV.fetch("OPENAI_ADMIN_TOKEN")
1473
+ end
1474
+
1475
+ # or
1341
1476
 
1477
+ client = OpenAI::Client.new(admin_token: "123abc")
1342
1478
  ```
1343
- begin
1344
- OpenAI::Client.new.models.retrieve(id: "gpt-4o")
1345
- rescue Faraday::Error => e
1346
- raise "Got a Faraday error: #{e}"
1479
+
1480
+ You can retrieve usage data for different endpoints and time periods:
1481
+
1482
+ ```ruby
1483
+ one_day_ago = Time.now.to_i - 86_400
1484
+
1485
+ # Retrieve costs data
1486
+ response = client.usage.costs(parameters: { start_time: one_day_ago })
1487
+ response["data"].each do |bucket|
1488
+ bucket["results"].each do |result|
1489
+ puts "#{Time.at(bucket["start_time"]).to_date}: $#{result.dig("amount", "value").round(2)}"
1347
1490
  end
1491
+ end
1492
+ => 2025-02-09: $0.0
1493
+ => 2025-02-10: $0.42
1494
+
1495
+ # Retrieve completions usage data
1496
+ response = client.usage.completions(parameters: { start_time: one_day_ago })
1497
+ puts response["data"]
1498
+
1499
+ # Retrieve embeddings usage data
1500
+ response = client.usage.embeddings(parameters: { start_time: one_day_ago })
1501
+ puts response["data"]
1502
+
1503
+ # Retrieve moderations usage data
1504
+ response = client.usage.moderations(parameters: { start_time: one_day_ago })
1505
+ puts response["data"]
1506
+
1507
+ # Retrieve image generation usage data
1508
+ response = client.usage.images(parameters: { start_time: one_day_ago })
1509
+ puts response["data"]
1510
+
1511
+ # Retrieve audio speech usage data
1512
+ response = client.usage.audio_speeches(parameters: { start_time: one_day_ago })
1513
+ puts response["data"]
1514
+
1515
+ # Retrieve audio transcription usage data
1516
+ response = client.usage.audio_transcriptions(parameters: { start_time: one_day_ago })
1517
+ puts response["data"]
1518
+
1519
+ # Retrieve vector stores usage data
1520
+ response = client.usage.vector_stores(parameters: { start_time: one_day_ago })
1521
+ puts response["data"]
1522
+ ```
1523
+
1524
+ ### Errors
1525
+
1526
+ HTTP errors can be caught like this:
1527
+
1528
+ ```ruby
1529
+ begin
1530
+ OpenAI::Client.new.models.retrieve(id: "gpt-4o")
1531
+ rescue Faraday::Error => e
1532
+ raise "Got a Faraday error: #{e}"
1533
+ end
1348
1534
  ```
1349
1535
 
1350
1536
  ## Development
@@ -1356,15 +1542,11 @@ To install this gem onto your local machine, run `bundle exec rake install`.
1356
1542
  To run all tests, execute the command `bundle exec rake`, which will also run the linter (Rubocop). This repository uses [VCR](https://github.com/vcr/vcr) to log API requests.
1357
1543
 
1358
1544
  > [!WARNING]
1359
- > If you have an `OPENAI_ACCESS_TOKEN` in your `ENV`, running the specs will use this to run the specs against the actual API, which will be slow and cost you money - 2 cents or more! Remove it from your environment with `unset` or similar if you just want to run the specs against the stored VCR responses.
1545
+ > If you have an `OPENAI_ACCESS_TOKEN` and `OPENAI_ADMIN_TOKEN` in your `ENV`, running the specs will hit the actual API, which will be slow and cost you money - 2 cents or more! Remove them from your environment with `unset` or similar if you just want to run the specs against the stored VCR responses.
1360
1546
 
1361
1547
  ## Release
1362
1548
 
1363
- First run the specs without VCR so they actually hit the API. This will cost 2 cents or more. Set OPENAI_ACCESS_TOKEN in your environment or pass it in like this:
1364
-
1365
- ```
1366
- OPENAI_ACCESS_TOKEN=123abc bundle exec rspec
1367
- ```
1549
+ First run the specs without VCR so they actually hit the API. This will cost 2 cents or more. Set OPENAI_ACCESS_TOKEN and OPENAI_ADMIN_TOKEN in your environment.
1368
1550
 
1369
1551
  Then update the version number in `version.rb`, update `CHANGELOG.md`, run `bundle install` to update Gemfile.lock, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
1370
1552