ruby-openai 7.3.1 → 7.4.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.
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