ruby-openai 4.2.0 → 5.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5654d4f5edeb9b912b06916b13c915d4a795e4ad64df4ae4be792ca562bdfd99
4
- data.tar.gz: b3b6457c556d9a4355afe7da1c514fc6029df5dc20e56a893981ec5410d14eac
3
+ metadata.gz: 996d39cd32c3c05c73efea0177c12d0751b5dda208b2855aaac440af7b2702d8
4
+ data.tar.gz: 65471a670e34f537fe4878322c87978f1c2beaf93336a7f2104baaa86b018c60
5
5
  SHA512:
6
- metadata.gz: '08e9e61ecc221384d0460ed5692ff5ac482e558219768402d05ed5b321e4aaf50ed94b26c8b938ae546da29db609b79f45439c0942f252f9059c6053a416f2dd'
7
- data.tar.gz: ddd59e60920f73d230a068e4d25a98704ea3e39203c45f0b295837d42e72a16a1d4064772cb6da9be35c65fb512a29cc617d325e3646e3a3c677263693ab9725
6
+ metadata.gz: deab41c7c7f4ee21b4ed1a17f289b147b2e4960b33fd12ce863d5bdb8c835a955215d01438890c1ab8d9a1c7026faba0e5b8359c1fe3d9139082f8de58dce616
7
+ data.tar.gz: 3309d1c3a68736816c4f3bd1d465021ee3f162b5f5c3dbb7915ed5ce6f3a8d7014f9f1c4b07cf630f3f90201bdbe0ec308f1dc00fb6b075f45546fe519afb553
@@ -0,0 +1,16 @@
1
+ FROM ruby:3.2.2-slim-bullseye
2
+
3
+ ENV TZ="Europe/London"
4
+
5
+ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
6
+ && apt-get -y install --no-install-recommends \
7
+ apt-utils \
8
+ build-essential \
9
+ curl \
10
+ git \
11
+ vim \
12
+ zsh
13
+
14
+ RUN gem install bundler
15
+
16
+ WORKDIR /workspace
@@ -0,0 +1,36 @@
1
+ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2
+ // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/ruby-rails-postgres
3
+ // Update the VARIANT arg in docker-compose.yml to pick a Ruby version
4
+ {
5
+ "name": "ruby-openai",
6
+ "dockerComposeFile": "docker-compose.yml",
7
+ "service": "app",
8
+ "workspaceFolder": "/workspace",
9
+ "containerEnv": {
10
+ "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}",
11
+ "GITHUB_USER": "${localEnv:GITHUB_USER}"
12
+ },
13
+ // Configure tool-specific properties.
14
+ "customizations": {
15
+ // Configure properties specific to VS Code.
16
+ "vscode": {
17
+ // Add the IDs of extensions you want installed when the container is created.
18
+ "extensions": [
19
+ "rebornix.Ruby",
20
+ "sleistner.vscode-fileutils",
21
+ "ms-azuretools.vscode-docker",
22
+ "samverschueren.final-newline",
23
+ "GitHub.copilot",
24
+ "usernamehw.remove-empty-lines",
25
+ "wingrunr21.vscode-ruby",
26
+ ]
27
+ }
28
+ },
29
+ // Use 'postCreateCommand' to run commands after the container is created.
30
+ "postCreateCommand": "bundle install",
31
+ // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
32
+ "features": {
33
+ "git": "os-provided",
34
+ "github-cli": "latest"
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ version: "3"
2
+
3
+ services:
4
+ app:
5
+ build:
6
+ context: ..
7
+ dockerfile: .devcontainer/Dockerfile
8
+
9
+ volumes:
10
+ - ..:/workspace:cached
11
+ - bundle_cache:/bundle
12
+
13
+ command: sleep infinity
14
+
15
+ environment:
16
+ TZ: Europe/London
17
+
18
+ volumes:
19
+ bundle_cache:
@@ -0,0 +1,13 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: alexrudall
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ otechie: # Replace with a single Otechie username
12
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/.gitignore CHANGED
@@ -1,16 +1,74 @@
1
- /.bundle/
2
- /.yardoc
3
- /_yardoc/
1
+ ### Ruby ###
2
+ *.gem
3
+ *.rbc
4
+ /.config
4
5
  /coverage/
5
- /doc/
6
+ /InstalledFiles
6
7
  /pkg/
7
8
  /spec/reports/
9
+ /spec/examples.txt
10
+ /test/tmp/
11
+ /test/version_tmp/
8
12
  /tmp/
13
+ /.bundle/
14
+ /.yardoc
15
+ /_yardoc/
16
+ /doc/
17
+
18
+
19
+ # Used by dotenv library to load environment variables.
20
+ .env
21
+
22
+ # Ignore Byebug command history file.
23
+ .byebug_history
24
+
25
+ ## Specific to RubyMotion:
26
+ .dat*
27
+ .repl_history
28
+ build/
29
+ *.bridgesupport
30
+ build-iPhoneOS/
31
+ build-iPhoneSimulator/
32
+
33
+ ## Specific to RubyMotion (use of CocoaPods):
34
+ #
35
+ # We recommend against adding the Pods directory to your .gitignore. However
36
+ # you should judge for yourself, the pros and cons are mentioned at:
37
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
38
+ # vendor/Pods/
39
+
40
+ ## Documentation cache and generated files:
41
+ /.yardoc/
42
+ /_yardoc/
43
+ /doc/
44
+ /rdoc/
45
+
46
+ ## Environment normalization:
47
+ /.bundle/
48
+ /vendor/bundle
49
+ /lib/bundler/man/
50
+
51
+ # for a library or gem, you might want to ignore these files since the code is
52
+ # intended to run in multiple environments; otherwise, check them in:
53
+ # Gemfile.lock
54
+ # .ruby-version
55
+ # .ruby-gemset
56
+
57
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
58
+ .rvmrc
59
+
60
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
61
+ # .rubocop-https?--*
9
62
 
10
63
  # rspec failure tracking
11
64
  .rspec_status
12
65
 
13
- .byebug_history
14
- .env
66
+ # IDE
67
+ .idea
68
+ .idea/
69
+ .idea/*
70
+ .vscode
71
+ .vs/
15
72
 
16
- *.gem
73
+ # Mac
74
+ .DS_Store
data/.rubocop.yml CHANGED
@@ -12,6 +12,11 @@ Layout/LineLength:
12
12
  Exclude:
13
13
  - "**/*.gemspec"
14
14
 
15
+ Lint/AmbiguousOperator:
16
+ # https://github.com/rubocop/rubocop/issues/4294
17
+ Exclude:
18
+ - "lib/openai/client.rb"
19
+
15
20
  Metrics/AbcSize:
16
21
  Max: 20
17
22
 
data/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.2.0] - 2023-10-30
9
+
10
+ ### Fix
11
+
12
+ - Added more spec-compliant SSE parsing: see here https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
13
+ - Fixes issue where OpenAI or an intermediary returns only partial JSON per chunk of streamed data
14
+ - Huge thanks to [@atesgoral](https://github.com/atesgoral) for this important fix!
15
+
16
+ ## [5.1.0] - 2023-08-20
17
+
18
+ ### Added
19
+
20
+ - Added rough_token_count to estimate tokens in a string according to OpenAI's "rules of thumb". Thank you to [@jamiemccarthy](https://github.com/jamiemccarthy) for the idea and implementation!
21
+
22
+ ## [5.0.0] - 2023-08-14
23
+
24
+ ### Added
25
+
26
+ - Support multi-tenant use of the gem! Each client now holds its own config, so you can create unlimited clients in the same project, for example to Azure and OpenAI, or for different headers, access keys, etc.
27
+ - [BREAKING-ish] This change should only break your usage of ruby-openai if you are directly calling class methods like `OpenAI::Client.get` for some reason, as they are now instance methods. Normal usage of the gem should be unaffected, just you can make new clients and they'll keep their own config if you want, overriding the global config.
28
+ - Huge thanks to [@petergoldstein](https://github.com/petergoldstein) for his original work on this, [@cthulhu](https://github.com/cthulhu) for testing and many others for reviews and suggestions.
29
+
30
+ ### Changed
31
+
32
+ - [BREAKING] Move audio related method to Audio model from Client model. You will need to update your code to handle this change, changing `client.translate` to `client.audio.translate` and `client.transcribe` to `client.audio.transcribe`.
33
+
34
+ ## [4.3.2] - 2023-08-14
35
+
36
+ ### Fixed
37
+
38
+ - Don't overwrite config extra-headers when making a client without different ones. Thanks to [@swistaczek](https://github.com/swistaczek) for raising this!
39
+ - Include extra-headers for Azure requests.
40
+
41
+ ## [4.3.1] - 2023-08-13
42
+
43
+ ### Fixed
44
+
45
+ - Tempfiles can now be sent to the API as well as Files, eg for Whisper. Thanks to [@codergeek121](https://github.com/codergeek121) for the fix!
46
+
47
+ ## [4.3.0] - 2023-08-12
48
+
49
+ ### Added
50
+
51
+ - Add extra-headers to config to allow setting openai-caching-proxy-worker TTL, Helicone Auth and anything else ya need. Ty to [@deltaguita](https://github.com/deltaguita) and [@marckohlbrugge](https://github.com/marckohlbrugge) for the PR!
52
+
8
53
  ## [4.2.0] - 2023-06-20
9
54
 
10
55
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ruby-openai (4.2.0)
4
+ ruby-openai (5.2.0)
5
+ event_stream_parser (>= 0.3.0, < 1.0.0)
5
6
  faraday (>= 1)
6
7
  faraday-multipart (>= 1)
7
8
 
@@ -16,7 +17,8 @@ GEM
16
17
  rexml
17
18
  diff-lcs (1.5.0)
18
19
  dotenv (2.8.1)
19
- faraday (2.7.6)
20
+ event_stream_parser (0.3.0)
21
+ faraday (2.7.10)
20
22
  faraday-net_http (>= 2.0, < 3.1)
21
23
  ruby2_keywords (>= 0.0.4)
22
24
  faraday-multipart (1.0.4)
data/README.md CHANGED
@@ -8,11 +8,9 @@ Use the [OpenAI API](https://openai.com/blog/openai-api/) with Ruby! 🤖❤️
8
8
 
9
9
  Stream text with GPT-4, transcribe and translate audio with Whisper, or create images with DALL·E...
10
10
 
11
- [Ruby AI Builders Discord](https://discord.gg/k4Uc224xVD)
11
+ 🚢 Based in the UK and want to hire me? Now you can! [railsai.com](https://railsai.com?utm_source=ruby-openai&utm_medium=readme&utm_id=26072023)
12
12
 
13
- [Quick guide to streaming ChatGPT with Rails 7 and Hotwire](https://gist.github.com/alexrudall/cb5ee1e109353ef358adb4e66631799d)
14
-
15
- Follow me on [Twitter](https://twitter.com/alexrudall) for more Ruby / AI content
13
+ [🎮 Ruby AI Builders Discord](https://discord.gg/k4Uc224xVD) | [🐦 Twitter](https://twitter.com/alexrudall) | [🧠 Anthropic Gem](https://github.com/alexrudall/anthropic) | [🚂 Midjourney Gem](https://github.com/alexrudall/midjourney)
16
14
 
17
15
  ### Bundler
18
16
 
@@ -24,13 +22,17 @@ gem "ruby-openai"
24
22
 
25
23
  And then execute:
26
24
 
25
+ ```bash
27
26
  $ bundle install
27
+ ```
28
28
 
29
29
  ### Gem install
30
30
 
31
31
  Or install with:
32
32
 
33
+ ```bash
33
34
  $ gem install ruby-openai
35
+ ```
34
36
 
35
37
  and require with:
36
38
 
@@ -68,15 +70,27 @@ Then you can create a client like this:
68
70
  client = OpenAI::Client.new
69
71
  ```
70
72
 
73
+ 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:
74
+
75
+ ```ruby
76
+ client = OpenAI::Client.new(access_token: "access_token_goes_here")
77
+ ```
78
+
71
79
  #### Custom timeout or base URI
72
80
 
73
- The default timeout for any request using this library is 120 seconds. You can change that by passing a number of seconds to the `request_timeout` when initializing the client. You can also change the base URI used for all requests, eg. to use observability tools like [Helicone](https://docs.helicone.ai/quickstart/integrate-in-one-line-of-code):
81
+ The default timeout for any request using this library is 120 seconds. You can change that by passing a number of seconds to the `request_timeout` when initializing the client. You can also change the base URI used for all requests, eg. to use observability tools like [Helicone](https://docs.helicone.ai/quickstart/integrate-in-one-line-of-code), and add arbitrary other headers e.g. for [openai-caching-proxy-worker](https://github.com/6/openai-caching-proxy-worker):
74
82
 
75
83
  ```ruby
76
84
  client = OpenAI::Client.new(
77
85
  access_token: "access_token_goes_here",
78
86
  uri_base: "https://oai.hconeai.com/",
79
- request_timeout: 240
87
+ request_timeout: 240,
88
+ extra_headers: {
89
+ "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
90
+ "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
91
+ "Helicone-Auth": "Bearer HELICONE_API_KEY", # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
92
+ "helicone-stream-force-format" => "true", # Use this with Helicone otherwise streaming drops chunks # https://github.com/alexrudall/ruby-openai/issues/251
93
+ }
80
94
  )
81
95
  ```
82
96
 
@@ -88,10 +102,15 @@ OpenAI.configure do |config|
88
102
  config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID") # Optional
89
103
  config.uri_base = "https://oai.hconeai.com/" # Optional
90
104
  config.request_timeout = 240 # Optional
105
+ config.extra_headers = {
106
+ "X-Proxy-TTL" => "43200", # For https://github.com/6/openai-caching-proxy-worker#specifying-a-cache-ttl
107
+ "X-Proxy-Refresh": "true", # For https://github.com/6/openai-caching-proxy-worker#refreshing-the-cache
108
+ "Helicone-Auth": "Bearer HELICONE_API_KEY" # For https://docs.helicone.ai/getting-started/integration-method/openai-proxy
109
+ } # Optional
91
110
  end
92
111
  ```
93
112
 
94
- ### Azure
113
+ #### Azure
95
114
 
96
115
  To use the [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/cognitive-services/openai/) API, you can configure the gem like this:
97
116
 
@@ -106,6 +125,18 @@ To use the [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/cognit
106
125
 
107
126
  where `AZURE_OPENAI_URI` is e.g. `https://custom-domain.openai.azure.com/openai/deployments/gpt-35-turbo`
108
127
 
128
+ ### Counting Tokens
129
+
130
+ OpenAI parses prompt text into [tokens](https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them), which are words or portions of words. (These tokens are unrelated to your API access_token.) Counting tokens can help you estimate your [costs](https://openai.com/pricing). It can also help you ensure your prompt text size is within the max-token limits of your model's context window, and choose an appropriate [`max_tokens`](https://platform.openai.com/docs/api-reference/chat/create#chat/create-max_tokens) completion parameter so your response will fit as well.
131
+
132
+ To estimate the token-count of your text:
133
+
134
+ ```ruby
135
+ OpenAI.rough_token_count("Your text")
136
+ ```
137
+
138
+ If you need a more accurate count, try [tiktoken_ruby](https://github.com/IAPark/tiktoken_ruby).
139
+
109
140
  ### Models
110
141
 
111
142
  There are different models that can be used to generate text. For a full list and to retrieve information about a single model:
@@ -164,6 +195,68 @@ client.chat(
164
195
  # => "Anna is a young woman in her mid-twenties, with wavy chestnut hair that falls to her shoulders..."
165
196
  ```
166
197
 
198
+ Note: the API docs state that token usage is included in the streamed chat chunk objects, but this doesn't currently appear to be the case. To count tokens while streaming, try `OpenAI.rough_token_count` or [tiktoken_ruby](https://github.com/IAPark/tiktoken_ruby).
199
+
200
+ ### Functions
201
+
202
+ You can describe and pass in functions and the model will intelligently choose to output a JSON object containing arguments to call those them. For example, if you want the model to use your method `get_current_weather` to get the current weather in a given location:
203
+
204
+ ```ruby
205
+ def get_current_weather(location:, unit: "fahrenheit")
206
+ # use a weather api to fetch weather
207
+ end
208
+
209
+ response =
210
+ client.chat(
211
+ parameters: {
212
+ model: "gpt-3.5-turbo-0613",
213
+ messages: [
214
+ {
215
+ "role": "user",
216
+ "content": "What is the weather like in San Francisco?",
217
+ },
218
+ ],
219
+ functions: [
220
+ {
221
+ name: "get_current_weather",
222
+ description: "Get the current weather in a given location",
223
+ parameters: {
224
+ type: :object,
225
+ properties: {
226
+ location: {
227
+ type: :string,
228
+ description: "The city and state, e.g. San Francisco, CA",
229
+ },
230
+ unit: {
231
+ type: "string",
232
+ enum: %w[celsius fahrenheit],
233
+ },
234
+ },
235
+ required: ["location"],
236
+ },
237
+ },
238
+ ],
239
+ },
240
+ )
241
+
242
+ message = response.dig("choices", 0, "message")
243
+
244
+ if message["role"] == "assistant" && message["function_call"]
245
+ function_name = message.dig("function_call", "name")
246
+ args =
247
+ JSON.parse(
248
+ message.dig("function_call", "arguments"),
249
+ { symbolize_names: true },
250
+ )
251
+
252
+ case function_name
253
+ when "get_current_weather"
254
+ get_current_weather(**args)
255
+ end
256
+ end
257
+ # => "The weather is nice 🌞"
258
+ ```
259
+
167
260
  ### Completions
168
261
 
169
262
  Hit the OpenAI API for a completion using other GPT-3 models:
@@ -202,7 +295,7 @@ You can use the embeddings endpoint to get a vector of numbers representing an i
202
295
  ```ruby
203
296
  response = client.embeddings(
204
297
  parameters: {
205
- model: "babbage-similarity",
298
+ model: "text-embedding-ada-002",
206
299
  input: "The food was delicious and the waiter..."
207
300
  }
208
301
  )
@@ -339,7 +432,7 @@ Whisper is a speech to text model that can be used to generate text based on aud
339
432
  The translations API takes as input the audio file in any of the supported languages and transcribes the audio into English.
340
433
 
341
434
  ```ruby
342
- response = client.translate(
435
+ response = client.audio.translate(
343
436
  parameters: {
344
437
  model: "whisper-1",
345
438
  file: File.open("path_to_file", "rb"),
@@ -353,7 +446,7 @@ puts response["text"]
353
446
  The transcriptions API takes as input the audio file you want to transcribe and returns the text in the desired output file format.
354
447
 
355
448
  ```ruby
356
- response = client.transcribe(
449
+ response = client.audio.transcribe(
357
450
  parameters: {
358
451
  model: "whisper-1",
359
452
  file: File.open("path_to_file", "rb"),
@@ -0,0 +1,15 @@
1
+ module OpenAI
2
+ class Audio
3
+ def initialize(client:)
4
+ @client = client
5
+ end
6
+
7
+ def transcribe(parameters: {})
8
+ @client.multipart_post(path: "/audio/transcriptions", parameters: parameters)
9
+ end
10
+
11
+ def translate(parameters: {})
12
+ @client.multipart_post(path: "/audio/translations", parameters: parameters)
13
+ end
14
+ end
15
+ end
data/lib/openai/client.rb CHANGED
@@ -1,56 +1,68 @@
1
1
  module OpenAI
2
2
  class Client
3
- extend OpenAI::HTTP
3
+ include OpenAI::HTTP
4
4
 
5
- def initialize(access_token: nil, organization_id: nil, uri_base: nil, request_timeout: nil)
6
- OpenAI.configuration.access_token = access_token if access_token
7
- OpenAI.configuration.organization_id = organization_id if organization_id
8
- OpenAI.configuration.uri_base = uri_base if uri_base
9
- OpenAI.configuration.request_timeout = request_timeout if request_timeout
5
+ CONFIG_KEYS = %i[
6
+ api_type
7
+ api_version
8
+ access_token
9
+ organization_id
10
+ uri_base
11
+ request_timeout
12
+ extra_headers
13
+ ].freeze
14
+ attr_reader *CONFIG_KEYS
15
+
16
+ def initialize(config = {})
17
+ CONFIG_KEYS.each do |key|
18
+ # Set instance variables like api_type & access_token. Fall back to global config
19
+ # if not present.
20
+ instance_variable_set("@#{key}", config[key] || OpenAI.configuration.send(key))
21
+ end
10
22
  end
11
23
 
12
24
  def chat(parameters: {})
13
- OpenAI::Client.json_post(path: "/chat/completions", parameters: parameters)
25
+ json_post(path: "/chat/completions", parameters: parameters)
14
26
  end
15
27
 
16
28
  def completions(parameters: {})
17
- OpenAI::Client.json_post(path: "/completions", parameters: parameters)
29
+ json_post(path: "/completions", parameters: parameters)
18
30
  end
19
31
 
20
32
  def edits(parameters: {})
21
- OpenAI::Client.json_post(path: "/edits", parameters: parameters)
33
+ json_post(path: "/edits", parameters: parameters)
22
34
  end
23
35
 
24
36
  def embeddings(parameters: {})
25
- OpenAI::Client.json_post(path: "/embeddings", parameters: parameters)
37
+ json_post(path: "/embeddings", parameters: parameters)
38
+ end
39
+
40
+ def audio
41
+ @audio ||= OpenAI::Audio.new(client: self)
26
42
  end
27
43
 
28
44
  def files
29
- @files ||= OpenAI::Files.new
45
+ @files ||= OpenAI::Files.new(client: self)
30
46
  end
31
47
 
32
48
  def finetunes
33
- @finetunes ||= OpenAI::Finetunes.new
49
+ @finetunes ||= OpenAI::Finetunes.new(client: self)
34
50
  end
35
51
 
36
52
  def images
37
- @images ||= OpenAI::Images.new
53
+ @images ||= OpenAI::Images.new(client: self)
38
54
  end
39
55
 
40
56
  def models
41
- @models ||= OpenAI::Models.new
57
+ @models ||= OpenAI::Models.new(client: self)
42
58
  end
43
59
 
44
60
  def moderations(parameters: {})
45
- OpenAI::Client.json_post(path: "/moderations", parameters: parameters)
46
- end
47
-
48
- def transcribe(parameters: {})
49
- OpenAI::Client.multipart_post(path: "/audio/transcriptions", parameters: parameters)
61
+ json_post(path: "/moderations", parameters: parameters)
50
62
  end
51
63
 
52
- def translate(parameters: {})
53
- OpenAI::Client.multipart_post(path: "/audio/translations", parameters: parameters)
64
+ def azure?
65
+ @api_type&.to_sym == :azure
54
66
  end
55
67
  end
56
68
  end
data/lib/openai/files.rb CHANGED
@@ -1,33 +1,32 @@
1
1
  module OpenAI
2
2
  class Files
3
- def initialize(access_token: nil, organization_id: nil)
4
- OpenAI.configuration.access_token = access_token if access_token
5
- OpenAI.configuration.organization_id = organization_id if organization_id
3
+ def initialize(client:)
4
+ @client = client
6
5
  end
7
6
 
8
7
  def list
9
- OpenAI::Client.get(path: "/files")
8
+ @client.get(path: "/files")
10
9
  end
11
10
 
12
11
  def upload(parameters: {})
13
12
  validate(file: parameters[:file])
14
13
 
15
- OpenAI::Client.multipart_post(
14
+ @client.multipart_post(
16
15
  path: "/files",
17
16
  parameters: parameters.merge(file: File.open(parameters[:file]))
18
17
  )
19
18
  end
20
19
 
21
20
  def retrieve(id:)
22
- OpenAI::Client.get(path: "/files/#{id}")
21
+ @client.get(path: "/files/#{id}")
23
22
  end
24
23
 
25
24
  def content(id:)
26
- OpenAI::Client.get(path: "/files/#{id}/content")
25
+ @client.get(path: "/files/#{id}/content")
27
26
  end
28
27
 
29
28
  def delete(id:)
30
- OpenAI::Client.delete(path: "/files/#{id}")
29
+ @client.delete(path: "/files/#{id}")
31
30
  end
32
31
 
33
32
  private
@@ -1,28 +1,27 @@
1
1
  module OpenAI
2
2
  class Finetunes
3
- def initialize(access_token: nil, organization_id: nil)
4
- OpenAI.configuration.access_token = access_token if access_token
5
- OpenAI.configuration.organization_id = organization_id if organization_id
3
+ def initialize(client:)
4
+ @client = client
6
5
  end
7
6
 
8
7
  def list
9
- OpenAI::Client.get(path: "/fine-tunes")
8
+ @client.get(path: "/fine-tunes")
10
9
  end
11
10
 
12
11
  def create(parameters: {})
13
- OpenAI::Client.json_post(path: "/fine-tunes", parameters: parameters)
12
+ @client.json_post(path: "/fine-tunes", parameters: parameters)
14
13
  end
15
14
 
16
15
  def retrieve(id:)
17
- OpenAI::Client.get(path: "/fine-tunes/#{id}")
16
+ @client.get(path: "/fine-tunes/#{id}")
18
17
  end
19
18
 
20
19
  def cancel(id:)
21
- OpenAI::Client.multipart_post(path: "/fine-tunes/#{id}/cancel")
20
+ @client.multipart_post(path: "/fine-tunes/#{id}/cancel")
22
21
  end
23
22
 
24
23
  def events(id:)
25
- OpenAI::Client.get(path: "/fine-tunes/#{id}/events")
24
+ @client.get(path: "/fine-tunes/#{id}/events")
26
25
  end
27
26
 
28
27
  def delete(fine_tuned_model:)
@@ -30,7 +29,7 @@ module OpenAI
30
29
  raise ArgumentError, "Please give a fine_tuned_model name, not a fine-tune ID"
31
30
  end
32
31
 
33
- OpenAI::Client.delete(path: "/models/#{fine_tuned_model}")
32
+ @client.delete(path: "/models/#{fine_tuned_model}")
34
33
  end
35
34
  end
36
35
  end
data/lib/openai/http.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require "event_stream_parser"
2
+
1
3
  module OpenAI
2
4
  module HTTP
3
5
  def get(path:)
@@ -53,53 +55,69 @@ module OpenAI
53
55
  # @param user_proc [Proc] The inner proc to call for each JSON object in the chunk.
54
56
  # @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON.
55
57
  def to_json_stream(user_proc:)
56
- proc do |chunk, _|
57
- chunk.scan(/(?:data|error): (\{.*\})/i).flatten.each do |data|
58
- user_proc.call(JSON.parse(data))
59
- rescue JSON::ParserError
60
- # Ignore invalid JSON.
58
+ parser = EventStreamParser::Parser.new
59
+
60
+ proc do |chunk, _bytes, env|
61
+ if env && env.status != 200
62
+ emit_json(json: chunk, user_proc: user_proc)
63
+ else
64
+ parser.feed(chunk) do |_type, data|
65
+ emit_json(json: data, user_proc: user_proc) unless data == "[DONE]"
66
+ end
61
67
  end
62
68
  end
63
69
  end
64
70
 
71
+ def emit_json(json:, user_proc:)
72
+ user_proc.call(JSON.parse(json))
73
+ rescue JSON::ParserError
74
+ # Ignore invalid JSON.
75
+ end
76
+
65
77
  def conn(multipart: false)
66
78
  Faraday.new do |f|
67
- f.options[:timeout] = OpenAI.configuration.request_timeout
79
+ f.options[:timeout] = @request_timeout
68
80
  f.request(:multipart) if multipart
69
81
  end
70
82
  end
71
83
 
72
84
  def uri(path:)
73
- if OpenAI.configuration.api_type == :azure
74
- base = File.join(OpenAI.configuration.uri_base, path)
75
- "#{base}?api-version=#{OpenAI.configuration.api_version}"
85
+ if azure?
86
+ base = File.join(@uri_base, path)
87
+ "#{base}?api-version=#{@api_version}"
76
88
  else
77
- File.join(OpenAI.configuration.uri_base, OpenAI.configuration.api_version, path)
89
+ File.join(@uri_base, @api_version, path)
78
90
  end
79
91
  end
80
92
 
81
93
  def headers
82
- return azure_headers if OpenAI.configuration.api_type == :azure
94
+ if azure?
95
+ azure_headers
96
+ else
97
+ openai_headers
98
+ end.merge(@extra_headers || {})
99
+ end
83
100
 
101
+ def openai_headers
84
102
  {
85
103
  "Content-Type" => "application/json",
86
- "Authorization" => "Bearer #{OpenAI.configuration.access_token}",
87
- "OpenAI-Organization" => OpenAI.configuration.organization_id
104
+ "Authorization" => "Bearer #{@access_token}",
105
+ "OpenAI-Organization" => @organization_id
88
106
  }
89
107
  end
90
108
 
91
109
  def azure_headers
92
110
  {
93
111
  "Content-Type" => "application/json",
94
- "api-key" => OpenAI.configuration.access_token
112
+ "api-key" => @access_token
95
113
  }
96
114
  end
97
115
 
98
116
  def multipart_parameters(parameters)
99
117
  parameters&.transform_values do |value|
100
- next value unless value.is_a?(File)
118
+ next value unless value.respond_to?(:close) # File or IO object.
101
119
 
102
- # Doesn't seem like OpenAI need mime_type yet, so not worth
120
+ # Doesn't seem like OpenAI needs mime_type yet, so not worth
103
121
  # the library to figure this out. Hence the empty string
104
122
  # as the second argument.
105
123
  Faraday::UploadIO.new(value, "", value.path)
data/lib/openai/images.rb CHANGED
@@ -1,20 +1,19 @@
1
1
  module OpenAI
2
2
  class Images
3
- def initialize(access_token: nil, organization_id: nil)
4
- OpenAI.configuration.access_token = access_token if access_token
5
- OpenAI.configuration.organization_id = organization_id if organization_id
3
+ def initialize(client: nil)
4
+ @client = client
6
5
  end
7
6
 
8
7
  def generate(parameters: {})
9
- OpenAI::Client.json_post(path: "/images/generations", parameters: parameters)
8
+ @client.json_post(path: "/images/generations", parameters: parameters)
10
9
  end
11
10
 
12
11
  def edit(parameters: {})
13
- OpenAI::Client.multipart_post(path: "/images/edits", parameters: open_files(parameters))
12
+ @client.multipart_post(path: "/images/edits", parameters: open_files(parameters))
14
13
  end
15
14
 
16
15
  def variations(parameters: {})
17
- OpenAI::Client.multipart_post(path: "/images/variations", parameters: open_files(parameters))
16
+ @client.multipart_post(path: "/images/variations", parameters: open_files(parameters))
18
17
  end
19
18
 
20
19
  private
data/lib/openai/models.rb CHANGED
@@ -1,16 +1,15 @@
1
1
  module OpenAI
2
2
  class Models
3
- def initialize(access_token: nil, organization_id: nil)
4
- OpenAI.configuration.access_token = access_token if access_token
5
- OpenAI.configuration.organization_id = organization_id if organization_id
3
+ def initialize(client:)
4
+ @client = client
6
5
  end
7
6
 
8
7
  def list
9
- OpenAI::Client.get(path: "/models")
8
+ @client.get(path: "/models")
10
9
  end
11
10
 
12
11
  def retrieve(id:)
13
- OpenAI::Client.get(path: "/models/#{id}")
12
+ @client.get(path: "/models/#{id}")
14
13
  end
15
14
  end
16
15
  end
@@ -1,3 +1,3 @@
1
1
  module OpenAI
2
- VERSION = "4.2.0".freeze
2
+ VERSION = "5.2.0".freeze
3
3
  end
data/lib/openai.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "openai/files"
7
7
  require_relative "openai/finetunes"
8
8
  require_relative "openai/images"
9
9
  require_relative "openai/models"
10
+ require_relative "openai/audio"
10
11
  require_relative "openai/version"
11
12
 
12
13
  module OpenAI
@@ -15,7 +16,8 @@ module OpenAI
15
16
 
16
17
  class Configuration
17
18
  attr_writer :access_token
18
- attr_accessor :api_type, :api_version, :organization_id, :uri_base, :request_timeout
19
+ attr_accessor :api_type, :api_version, :organization_id, :uri_base, :request_timeout,
20
+ :extra_headers
19
21
 
20
22
  DEFAULT_API_VERSION = "v1".freeze
21
23
  DEFAULT_URI_BASE = "https://api.openai.com/".freeze
@@ -28,6 +30,7 @@ module OpenAI
28
30
  @organization_id = nil
29
31
  @uri_base = DEFAULT_URI_BASE
30
32
  @request_timeout = DEFAULT_REQUEST_TIMEOUT
33
+ @extra_headers = nil
31
34
  end
32
35
 
33
36
  def access_token
@@ -49,4 +52,16 @@ module OpenAI
49
52
  def self.configure
50
53
  yield(configuration)
51
54
  end
55
+
56
+ # Estimate the number of tokens in a string, using the rules of thumb from OpenAI:
57
+ # https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
58
+ def self.rough_token_count(content = "")
59
+ raise ArgumentError, "rough_token_count requires a string" unless content.is_a? String
60
+ return 0 if content.empty?
61
+
62
+ count_by_chars = content.size / 4.0
63
+ count_by_words = content.split.size * 4.0 / 3
64
+ estimate = ((count_by_chars + count_by_words) / 2.0).round
65
+ [1, estimate].max
66
+ end
52
67
  end
data/ruby-openai.gemspec CHANGED
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
26
  spec.require_paths = ["lib"]
27
27
 
28
+ spec.add_dependency "event_stream_parser", ">= 0.3.0", "< 1.0.0"
28
29
  spec.add_dependency "faraday", ">= 1"
29
30
  spec.add_dependency "faraday-multipart", ">= 1"
30
31
  end
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-openai
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-20 00:00:00.000000000 Z
11
+ date: 2023-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: event_stream_parser
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.3.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 1.0.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.3.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: faraday
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -38,7 +58,7 @@ dependencies:
38
58
  - - ">="
39
59
  - !ruby/object:Gem::Version
40
60
  version: '1'
41
- description:
61
+ description:
42
62
  email:
43
63
  - alexrudall@users.noreply.github.com
44
64
  executables: []
@@ -46,6 +66,10 @@ extensions: []
46
66
  extra_rdoc_files: []
47
67
  files:
48
68
  - ".circleci/config.yml"
69
+ - ".devcontainer/Dockerfile"
70
+ - ".devcontainer/devcontainer.json"
71
+ - ".devcontainer/docker-compose.yml"
72
+ - ".github/FUNDING.yml"
49
73
  - ".github/ISSUE_TEMPLATE/bug_report.md"
50
74
  - ".github/ISSUE_TEMPLATE/feature_request.md"
51
75
  - ".github/dependabot.yml"
@@ -63,6 +87,7 @@ files:
63
87
  - bin/console
64
88
  - bin/setup
65
89
  - lib/openai.rb
90
+ - lib/openai/audio.rb
66
91
  - lib/openai/client.rb
67
92
  - lib/openai/compatibility.rb
68
93
  - lib/openai/files.rb
@@ -82,7 +107,7 @@ metadata:
82
107
  source_code_uri: https://github.com/alexrudall/ruby-openai
83
108
  changelog_uri: https://github.com/alexrudall/ruby-openai/blob/main/CHANGELOG.md
84
109
  rubygems_mfa_required: 'true'
85
- post_install_message:
110
+ post_install_message:
86
111
  rdoc_options: []
87
112
  require_paths:
88
113
  - lib
@@ -97,8 +122,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
97
122
  - !ruby/object:Gem::Version
98
123
  version: '0'
99
124
  requirements: []
100
- rubygems_version: 3.4.12
101
- signing_key:
125
+ rubygems_version: 3.4.10
126
+ signing_key:
102
127
  specification_version: 4
103
128
  summary: "OpenAI API + Ruby! \U0001F916❤️"
104
129
  test_files: []