openai.rb 0.0.0 → 0.0.3

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +27 -0
  3. data/.rubocop.yml +18 -0
  4. data/.ruby-version +1 -1
  5. data/Gemfile +9 -5
  6. data/Gemfile.lock +29 -24
  7. data/README.md +401 -0
  8. data/bin/console +9 -4
  9. data/lib/openai/api/cache.rb +137 -0
  10. data/lib/openai/api/client.rb +86 -0
  11. data/lib/openai/api/resource.rb +232 -0
  12. data/lib/openai/api/response.rb +384 -0
  13. data/lib/openai/api.rb +75 -0
  14. data/lib/openai/chat.rb +125 -0
  15. data/lib/openai/tokenizer.rb +50 -0
  16. data/lib/openai/util.rb +47 -0
  17. data/lib/openai/version.rb +1 -1
  18. data/lib/openai.rb +38 -357
  19. data/openai.gemspec +9 -3
  20. data/spec/data/sample_french.mp3 +0 -0
  21. data/spec/data/sample_image.png +0 -0
  22. data/spec/data/sample_image_mask.png +0 -0
  23. data/spec/shared/api_resource_context.rb +22 -0
  24. data/spec/spec_helper.rb +4 -0
  25. data/spec/unit/openai/api/audio_spec.rb +78 -0
  26. data/spec/unit/openai/api/cache_spec.rb +115 -0
  27. data/spec/unit/openai/api/chat_completions_spec.rb +130 -0
  28. data/spec/unit/openai/api/completions_spec.rb +125 -0
  29. data/spec/unit/openai/api/edits_spec.rb +40 -0
  30. data/spec/unit/openai/api/embeddings_spec.rb +45 -0
  31. data/spec/unit/openai/api/files_spec.rb +163 -0
  32. data/spec/unit/openai/api/fine_tunes_spec.rb +322 -0
  33. data/spec/unit/openai/api/images_spec.rb +137 -0
  34. data/spec/unit/openai/api/models_spec.rb +98 -0
  35. data/spec/unit/openai/api/moderations_spec.rb +63 -0
  36. data/spec/unit/openai/api/response_spec.rb +203 -0
  37. data/spec/unit/openai/chat_spec.rb +32 -0
  38. data/spec/unit/openai/tokenizer_spec.rb +45 -0
  39. data/spec/unit/openai_spec.rb +47 -736
  40. metadata +97 -7
  41. data/bin/codegen +0 -371
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OpenAI
4
+ class API
5
+ class Cache
6
+ include Concord.new(:client, :cache)
7
+
8
+ def get(route)
9
+ read_cache_or_apply(verb: :get, route: route) do
10
+ client.get(route)
11
+ end
12
+ end
13
+
14
+ # Caching is a no-op for delete requests since caching a delete does
15
+ # not really make sense
16
+ def delete(...)
17
+ client.delete(...)
18
+ end
19
+
20
+ def post(route, **body)
21
+ read_cache_or_apply(verb: :post, route: route, body: body, format: :json) do
22
+ client.post(route, **body)
23
+ end
24
+ end
25
+
26
+ def post_form_multipart(route, **body)
27
+ read_cache_or_apply(verb: :post, route: route, body: body, format: :form) do
28
+ client.post_form_multipart(route, **body)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def read_cache_or_apply(...)
35
+ target = cache_target(...).unique_id
36
+
37
+ if cache.cached?(target)
38
+ cache.read(target)
39
+ else
40
+ yield.tap do |result|
41
+ cache.write(target, result)
42
+ end
43
+ end
44
+ end
45
+
46
+ def cache_target(verb:, route:, body: nil, format: nil)
47
+ Target.new(
48
+ verb: verb,
49
+ api_key: client.api_key,
50
+ route: route,
51
+ body: body,
52
+ format: format
53
+ )
54
+ end
55
+
56
+ class Target
57
+ include Anima.new(:verb, :api_key, :route, :body, :format)
58
+ include Memoizable
59
+
60
+ def unique_id
61
+ serialized = JSON.dump(serialize_for_cache)
62
+ digest = Digest::SHA256.hexdigest(serialized)
63
+ bare_route = route.delete_prefix('/v1/')
64
+ prefix = "#{verb}_#{bare_route}".gsub('/', '_')
65
+ fingerprint = digest.slice(0, 8)
66
+
67
+ "#{prefix}_#{fingerprint}"
68
+ end
69
+ memoize :unique_id
70
+
71
+ def serialize_for_cache
72
+ data = to_h
73
+ if data[:body]
74
+ data[:body] = data[:body].transform_values do |value|
75
+ if value.instance_of?(HTTP::FormData::File)
76
+ Digest::SHA256.hexdigest(value.to_s)
77
+ else
78
+ value
79
+ end
80
+ end
81
+ end
82
+ data
83
+ end
84
+ end
85
+
86
+ class Strategy
87
+ include AbstractType
88
+
89
+ abstract_method :cached?
90
+ abstract_method :read
91
+ abstract_method :write
92
+
93
+ class Memory < self
94
+ include Concord.new(:cache)
95
+
96
+ def initialize
97
+ super({})
98
+ end
99
+
100
+ def cached?(target)
101
+ cache.key?(target)
102
+ end
103
+
104
+ def write(target, result)
105
+ cache[target] = result
106
+ end
107
+
108
+ def read(target)
109
+ cache.fetch(target)
110
+ end
111
+ end
112
+
113
+ class FileSystem < self
114
+ include Concord.new(:cache_dir)
115
+
116
+ def cached?(target)
117
+ cache_file(target).file?
118
+ end
119
+
120
+ def write(target, result)
121
+ cache_file(target).write(result)
122
+ end
123
+
124
+ def read(target)
125
+ cache_file(target).read
126
+ end
127
+
128
+ private
129
+
130
+ def cache_file(target)
131
+ cache_dir.join("#{target}.json")
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OpenAI
4
+ class API
5
+ class Client
6
+ include Concord.new(:api_key, :http)
7
+
8
+ public :api_key
9
+
10
+ HOST = Addressable::URI.parse('https://api.openai.com/v1')
11
+
12
+ def initialize(api_key, http: HTTP)
13
+ super(api_key, http)
14
+ end
15
+
16
+ def inspect
17
+ "#<#{self.class}>"
18
+ end
19
+
20
+ def get(route)
21
+ unwrap_response(json_http_client.get(url_for(route)))
22
+ end
23
+
24
+ def delete(route)
25
+ unwrap_response(json_http_client.delete(url_for(route)))
26
+ end
27
+
28
+ def post(route, **body)
29
+ url = url_for(route)
30
+ if block_given?
31
+ json_http_client.persistent(url) do |connection|
32
+ response = connection.post(url, json: body)
33
+
34
+ # Data comes in as JSON frames like so:
35
+ #
36
+ # data: {"choices": [{"text": "He"}]}
37
+ # data: {"choices": [{"text": "llo, "}]}
38
+ # data: {"choices": [{"text": "Wor"}]}
39
+ # data: {"choices": [{"text": "ld!"}]}
40
+ # data: [DONE]
41
+ #
42
+ # (The actual frames are fully formed JSON objects just like a
43
+ # non-streamed response, the examples above are just for brevity)
44
+ response.body.each do |chunk|
45
+ chunk.split("\n\n").each do |part|
46
+ frame = part.delete_prefix('data: ').strip
47
+
48
+ yield(frame) unless frame == '[DONE]' || frame.empty?
49
+ end
50
+ end
51
+ end
52
+
53
+ # Return nil since we aren't reconstructing what the API would have
54
+ # returned if we had not streamed the response
55
+ nil
56
+ else
57
+ unwrap_response(json_http_client.post(url, json: body))
58
+ end
59
+ end
60
+
61
+ def post_form_multipart(route, **body)
62
+ unwrap_response(http_client.post(url_for(route), form: body))
63
+ end
64
+
65
+ private
66
+
67
+ def url_for(route)
68
+ HOST.join(route).to_str
69
+ end
70
+
71
+ def unwrap_response(response)
72
+ raise API::Error.parse(response) unless response.status.success?
73
+
74
+ response.body.to_str
75
+ end
76
+
77
+ def json_http_client
78
+ http_client.headers('Content-Type' => 'application/json')
79
+ end
80
+
81
+ def http_client
82
+ http.headers('Authorization' => "Bearer #{api_key}")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OpenAI
4
+ class API
5
+ class Resource
6
+ include Concord.new(:client)
7
+ include AbstractType
8
+
9
+ private
10
+
11
+ def post(...)
12
+ client.post(...)
13
+ end
14
+
15
+ def post_form_multipart(...)
16
+ client.post_form_multipart(...)
17
+ end
18
+
19
+ def get(...)
20
+ client.get(...)
21
+ end
22
+
23
+ def form_file(path)
24
+ absolute_path = Pathname.new(path).expand_path.to_s
25
+ HTTP::FormData::File.new(absolute_path)
26
+ end
27
+
28
+ def create_and_maybe_stream(
29
+ endpoint,
30
+ full_response_type:, stream: false,
31
+ chunk_response_type: full_response_type,
32
+ **kwargs
33
+ )
34
+ payload = kwargs.merge(stream: stream)
35
+
36
+ raise 'Streaming responses require a block' if stream && !block_given?
37
+ raise 'Non-streaming responses do not support blocks' if !stream && block_given?
38
+
39
+ if stream
40
+ post(endpoint, **payload) do |chunk|
41
+ yield(chunk_response_type.from_json(chunk))
42
+ end
43
+
44
+ nil
45
+ else
46
+ full_response_type.from_json(
47
+ post(endpoint, **payload)
48
+ )
49
+ end
50
+ end
51
+ end
52
+
53
+ class Completion < Resource
54
+ def create(model:, **kwargs, &block)
55
+ create_and_maybe_stream(
56
+ '/v1/completions',
57
+ model: model,
58
+ full_response_type: Response::Completion,
59
+ **kwargs,
60
+ &block
61
+ )
62
+ end
63
+ end
64
+
65
+ class ChatCompletion < Resource
66
+ def create(model:, messages:, **kwargs, &block)
67
+ create_and_maybe_stream(
68
+ '/v1/chat/completions',
69
+ model: model,
70
+ messages: messages,
71
+ full_response_type: Response::ChatCompletion,
72
+ chunk_response_type: Response::ChatCompletionChunk,
73
+ **kwargs,
74
+ &block
75
+ )
76
+ end
77
+ end
78
+
79
+ class Embedding < Resource
80
+ def create(model:, input:, **kwargs)
81
+ Response::Embedding.from_json(
82
+ post('/v1/embeddings', model: model, input: input, **kwargs)
83
+ )
84
+ end
85
+ end
86
+
87
+ class Model < Resource
88
+ def list
89
+ Response::ListModel.from_json(get('/v1/models'))
90
+ end
91
+
92
+ def fetch(model_id)
93
+ Response::Model.from_json(
94
+ get("/v1/models/#{model_id}")
95
+ )
96
+ end
97
+ end
98
+
99
+ class Moderation < Resource
100
+ def create(input:, model:)
101
+ Response::Moderation.from_json(
102
+ post('/v1/moderations', input: input, model: model)
103
+ )
104
+ end
105
+ end
106
+
107
+ class Edit < Resource
108
+ def create(model:, instruction:, **kwargs)
109
+ Response::Edit.from_json(
110
+ post('/v1/edits', model: model, instruction: instruction, **kwargs)
111
+ )
112
+ end
113
+ end
114
+
115
+ class File < Resource
116
+ def create(file:, purpose:)
117
+ Response::File.from_json(
118
+ post_form_multipart('/v1/files', file: form_file(file), purpose: purpose)
119
+ )
120
+ end
121
+
122
+ def list
123
+ Response::FileList.from_json(
124
+ get('/v1/files')
125
+ )
126
+ end
127
+
128
+ def delete(file_id)
129
+ Response::File.from_json(
130
+ client.delete("/v1/files/#{file_id}")
131
+ )
132
+ end
133
+
134
+ def fetch(file_id)
135
+ Response::File.from_json(
136
+ get("/v1/files/#{file_id}")
137
+ )
138
+ end
139
+
140
+ def get_content(file_id)
141
+ get("/v1/files/#{file_id}/content")
142
+ end
143
+ end
144
+
145
+ class FineTune < Resource
146
+ def list
147
+ Response::FineTuneList.from_json(
148
+ get('/v1/fine-tunes')
149
+ )
150
+ end
151
+
152
+ def create(training_file:, **kwargs)
153
+ Response::FineTune.from_json(
154
+ post('/v1/fine-tunes', training_file: training_file, **kwargs)
155
+ )
156
+ end
157
+
158
+ def fetch(fine_tune_id)
159
+ Response::FineTune.from_json(
160
+ get("/v1/fine-tunes/#{fine_tune_id}")
161
+ )
162
+ end
163
+
164
+ def cancel(fine_tune_id)
165
+ Response::FineTune.from_json(
166
+ post("/v1/fine-tunes/#{fine_tune_id}/cancel")
167
+ )
168
+ end
169
+
170
+ def list_events(fine_tune_id)
171
+ Response::FineTuneEventList.from_json(
172
+ get("/v1/fine-tunes/#{fine_tune_id}/events")
173
+ )
174
+ end
175
+ end
176
+
177
+ class Image < Resource
178
+ def create(prompt:, **kwargs)
179
+ Response::ImageGeneration.from_json(
180
+ post('/v1/images/generations', prompt: prompt, **kwargs)
181
+ )
182
+ end
183
+
184
+ def create_variation(image:, **kwargs)
185
+ Response::ImageVariation.from_json(
186
+ post_form_multipart('/v1/images/variations', {
187
+ image: form_file(image),
188
+ **kwargs
189
+ })
190
+ )
191
+ end
192
+
193
+ def edit(image:, prompt:, mask: nil, **kwargs)
194
+ params = {
195
+ image: form_file(image),
196
+ prompt: prompt,
197
+ **kwargs
198
+ }
199
+
200
+ params[:mask] = form_file(mask) if mask
201
+
202
+ Response::ImageEdit.from_json(
203
+ post_form_multipart('/v1/images/edits', **params)
204
+ )
205
+ end
206
+ end
207
+
208
+ class Audio < Resource
209
+ def transcribe(file:, model:, **kwargs)
210
+ Response::Transcription.from_json(
211
+ post_form_multipart(
212
+ '/v1/audio/transcriptions',
213
+ file: form_file(file),
214
+ model: model,
215
+ **kwargs
216
+ )
217
+ )
218
+ end
219
+
220
+ def translate(file:, model:, **kwargs)
221
+ Response::Transcription.from_json(
222
+ post_form_multipart(
223
+ '/v1/audio/translations',
224
+ file: form_file(file),
225
+ model: model,
226
+ **kwargs
227
+ )
228
+ )
229
+ end
230
+ end
231
+ end
232
+ end