ai-chat 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b59ea831d946cd885c9df7c8524d0bd4cf26361ad9190780969698ac8db8b36
4
- data.tar.gz: e21d404bc9c5e0ab5e1f0951053a6c159393f81f743460fdafbca0d032542895
3
+ metadata.gz: 12727b49e500d9c3c117b0d8a4df291f0b050eae0b12c90d7cd992ac93881c42
4
+ data.tar.gz: 2ebf913f409bdfbebcc5cd12d8a4a87346be894564d75a6c7811157b9619ecce
5
5
  SHA512:
6
- metadata.gz: a94b918c1feecb3e9983a0d3c0411469084f3529167a45119ff77609adad1efc1c7ae2907817a6737ae865507d6e84eeeb8e24387b4da27fede223d3ed20f5a4
7
- data.tar.gz: c5f9508ac39c6b952724ebf9ac7dbef96418e5f59f8ce520f314a5255dad4065b500537daa3f108fe8640f9f015b7c2c6b48722dfaa1ddf641bbee8b4a4d5164
6
+ metadata.gz: 3707fdf80a68ce541b0c4fb3ab362f2404e2b0e78a3e7bd86ec140702f4b2939a6c2740b12a58d39212aceb24607c87648948f5d93bb5fcacffbf7200e35e2e0
7
+ data.tar.gz: 9d3e23578c90251da1fb08351aafe48fa5c7a82ec330644a6cffbe156fd42d58bcfbebd42be93df5a7a680cddfe0855c8dc2770ef3c8a0a70f2c4d4e8fa894f8
data/README.md CHANGED
@@ -82,18 +82,18 @@ pp a.messages
82
82
  # => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}]
83
83
 
84
84
  # Generate the next message using AI
85
- a.generate! # => "Matz is nice and so we are nice" (or similar)
85
+ a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) }
86
86
 
87
87
  # Your array now includes the assistant's response
88
88
  pp a.messages
89
89
  # => [
90
90
  # {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"},
91
- # {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => #<AI::Chat::Response id=resp_abc... model=gpt-4.1-nano tokens=12>}
91
+ # {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 } }
92
92
  # ]
93
93
 
94
94
  # Continue the conversation
95
95
  a.add("What about Rails?")
96
- a.generate! # => "Convention over configuration."
96
+ a.generate! # => { :role => "assistant", :content => "Convention over configuration."}
97
97
  ```
98
98
 
99
99
  ## Understanding the Data Structure
@@ -108,7 +108,7 @@ That's it! You're building something like this:
108
108
  [
109
109
  {:role => "system", :content => "You are a helpful assistant"},
110
110
  {:role => "user", :content => "Hello!"},
111
- {:role => "assistant", :content => "Hi there! How can I help you today?", :response => #<AI::Chat::Response id=resp_abc... model=gpt-4.1-nano tokens=12>}
111
+ {:role => "assistant", :content => "Hi there! How can I help you today?", :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 } }
112
112
  ]
113
113
  ```
114
114
 
@@ -135,7 +135,7 @@ pp b.messages
135
135
  # ]
136
136
 
137
137
  # Generate a response
138
- b.generate! # => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'"
138
+ b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" }
139
139
  ```
140
140
 
141
141
  ### Convenience Methods
@@ -237,7 +237,7 @@ h.messages.last[:content]
237
237
  # => "Here's how to boil an egg..."
238
238
 
239
239
  # Or use the convenient shortcut
240
- h.last
240
+ h.last[:content]
241
241
  # => "Here's how to boil an egg..."
242
242
  ```
243
243
 
@@ -277,10 +277,11 @@ i.schema = '{"name": "nutrition_values","strict": true,"schema": {"type": "objec
277
277
  i.user("1 slice of pizza")
278
278
 
279
279
  response = i.generate!
280
+ data = response[:content]
280
281
  # => {:fat=>15, :protein=>12, :carbs=>35, :total_calories=>285}
281
282
 
282
283
  # The response is parsed JSON, not a string!
283
- response[:total_calories] # => 285
284
+ data[:total_calories] # => 285
284
285
  ```
285
286
 
286
287
  ### Schema Formats
@@ -442,14 +443,14 @@ a = AI::Chat.new
442
443
  a.user("What color is the object in this photo?", image: "thing.png")
443
444
  a.generate! # => "Red"
444
445
  a.user("What is the object in the photo?")
445
- a.generate! # => "I don't see a photo"
446
+ a.generate! # => { :content => "I don't see a photo", ... }
446
447
 
447
448
  b = AI::Chat.new
448
449
  b.user("What color is the object in this photo?", image: "thing.png")
449
450
  b.generate! # => "Red"
450
451
  b.user("What is the object in the photo?")
451
452
  b.previous_response_id = nil
452
- b.generate! # => "An apple"
453
+ b.generate! # => { :content => "An apple", ... }
453
454
  ```
454
455
 
455
456
  If you don't set `previous_response_id` to `nil`, the model won't have the old image(s) to work with.
@@ -462,7 +463,7 @@ You can enable OpenAI's image generation tool:
462
463
  a = AI::Chat.new
463
464
  a.image_generation = true
464
465
  a.user("Draw a picture of a kitten")
465
- a.generate! # => "Here is your picture of a kitten:"
466
+ a.generate! # => { :content => "Here is your picture of a kitten:", ... }
466
467
  ```
467
468
 
468
469
  By default, images are saved to `./images`. You can configure a different location:
@@ -472,7 +473,7 @@ a = AI::Chat.new
472
473
  a.image_generation = true
473
474
  a.image_folder = "./my_images"
474
475
  a.user("Draw a picture of a kitten")
475
- a.generate! # => "Here is your picture of a kitten:"
476
+ a.generate! # => { :content => "Here is your picture of a kitten:", ... }
476
477
  ```
477
478
 
478
479
  Images are saved in timestamped subfolders using ISO 8601 basic format. For example:
@@ -510,9 +511,18 @@ a = AI::Chat.new
510
511
  a.image_generation = true
511
512
  a.image_folder = "./images"
512
513
  a.user("Draw a picture of a kitten")
513
- a.generate! # => "Here is a picture of a kitten:"
514
+ a.generate! # => { :content => "Here is a picture of a kitten:", ... }
514
515
  a.user("Make it even cuter")
515
- a.generate! # => "Here is the kitten, but even cuter:"
516
+ a.generate! # => { :content => "Here is the kitten, but even cuter:", ... }
517
+ ```
518
+
519
+ ## Code Interpreter
520
+
521
+ ```ruby
522
+ y = AI::Chat.new
523
+ y.code_interpreter = true
524
+ y.user("Plot y = 2x*3 when x is -5 to 5.")
525
+ y.generate! # => {:content => "Here is the graph.", ... }
516
526
  ```
517
527
 
518
528
  ## Building Conversations Without API Calls
@@ -577,14 +587,14 @@ pp t.messages.last
577
587
  # => {
578
588
  # :role => "assistant",
579
589
  # :content => "Hello! How can I help you today?",
580
- # :response => #<AI::Response id=resp_abc... model=gpt-4.1-nano tokens=12>
590
+ # :response => { id=resp_abc... model=gpt-4.1-nano tokens=12 }
581
591
  # }
582
592
 
583
593
  # Access detailed information
584
594
  response = t.last[:response]
585
- response.id # => "resp_abc123..."
586
- response.model # => "gpt-4.1-nano"
587
- response.usage # => {:prompt_tokens=>5, :completion_tokens=>7, :total_tokens=>12}
595
+ response[:id] # => "resp_abc123..."
596
+ response[:model] # => "gpt-4.1-nano"
597
+ response[:usage] # => {:prompt_tokens=>5, :completion_tokens=>7, :total_tokens=>12}
588
598
  ```
589
599
 
590
600
  This information is useful for:
@@ -599,7 +609,7 @@ You can also, if you know a response ID, continue an old conversation by setting
599
609
  t = AI::Chat.new
600
610
  t.user("Hello!")
601
611
  t.generate!
602
- old_id = t.last[:response].id # => "resp_abc123..."
612
+ old_id = t.last[:response][:id] # => "resp_abc123..."
603
613
 
604
614
  # Some time in the future...
605
615
 
data/ai-chat.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "ai-chat"
5
- spec.version = "0.2.2"
5
+ spec.version = "0.2.4"
6
6
  spec.authors = ["Raghu Betina"]
7
7
  spec.email = ["raghu@firstdraft.com"]
8
8
  spec.homepage = "https://github.com/firstdraft/ai-chat"
@@ -23,6 +23,7 @@ Gem::Specification.new do |spec|
23
23
  spec.add_runtime_dependency "marcel", "~> 1.0"
24
24
  spec.add_runtime_dependency "base64", "> 0.1.1"
25
25
  spec.add_runtime_dependency "json", "~> 2.0"
26
+ spec.add_runtime_dependency "tty-spinner", "~> 0.9.3"
26
27
 
27
28
  spec.add_development_dependency "dotenv"
28
29
  spec.add_development_dependency "refinements", "~> 11.1"
@@ -0,0 +1,77 @@
1
+ require "amazing_print"
2
+ # :reek:IrresponsibleModule
3
+ module AmazingPrint
4
+ module AI
5
+ def self.included(base)
6
+ base.send :alias_method, :cast_without_ai, :cast
7
+ base.send :alias_method, :cast, :cast_with_ai
8
+ end
9
+
10
+ def cast_with_ai(object, type)
11
+ case object
12
+ when ::AI::Chat
13
+ :ai_object
14
+ else
15
+ cast_without_ai(object, type)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def awesome_ai_object(object)
22
+ case object
23
+ when ::AI::Chat
24
+ format_ai_chat(object)
25
+ else
26
+ awesome_object(object)
27
+ end
28
+ end
29
+
30
+ # :reek:DuplicateMethodCall
31
+ # :reek:FeatureEnvy
32
+ # :reek:NilCheck
33
+ # :reek:TooManyStatements
34
+ def format_ai_chat(chat)
35
+ vars = []
36
+
37
+ # Format messages with truncation
38
+ if chat.instance_variable_defined?(:@messages)
39
+ messages = chat.instance_variable_get(:@messages).map do |msg|
40
+ truncated_msg = msg.dup
41
+ if msg[:content].is_a?(String) && msg[:content].length > 80
42
+ truncated_msg[:content] = msg[:content][0..77] + "..."
43
+ end
44
+ truncated_msg
45
+ end
46
+ vars << ["@messages", messages]
47
+ end
48
+
49
+ # Add other variables (except sensitive ones)
50
+ skip_vars = [:@api_key, :@client, :@messages]
51
+ chat.instance_variables.sort.each do |var|
52
+ next if skip_vars.include?(var)
53
+ value = chat.instance_variable_get(var)
54
+ vars << [var.to_s, value] unless value.nil?
55
+ end
56
+
57
+ format_object(chat, vars)
58
+ end
59
+
60
+ # :reek:TooManyStatements
61
+ # :reek:DuplicateMethodCall
62
+ def format_object(object, vars)
63
+ data = vars.map do |(name, value)|
64
+ name = colorize(name, :variable) unless @options[:plain]
65
+ "#{name}: #{inspector.awesome(value)}"
66
+ end
67
+
68
+ if @options[:multiline]
69
+ "#<#{object.class}\n#{data.map { |line| " #{line}" }.join("\n")}\n>"
70
+ else
71
+ "#<#{object.class} #{data.join(', ')}>"
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ AmazingPrint::Formatter.send(:include, AmazingPrint::AI)
data/lib/ai/chat.rb CHANGED
@@ -7,8 +7,8 @@ require "openai"
7
7
  require "pathname"
8
8
  require "stringio"
9
9
  require "fileutils"
10
-
11
- require_relative "response"
10
+ require "tty-spinner"
11
+ require "timeout"
12
12
 
13
13
  module AI
14
14
  # :reek:MissingSafeMethod { exclude: [ generate! ] }
@@ -18,7 +18,7 @@ module AI
18
18
  # :reek:IrresponsibleModule
19
19
  class Chat
20
20
  # :reek:Attribute
21
- attr_accessor :messages, :model, :web_search, :previous_response_id, :image_generation, :image_folder
21
+ attr_accessor :background, :code_interpreter, :image_generation, :image_folder, :messages, :model, :previous_response_id, :web_search
22
22
  attr_reader :reasoning_effort, :client, :schema
23
23
 
24
24
  VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze
@@ -36,16 +36,16 @@ module AI
36
36
 
37
37
  # :reek:TooManyStatements
38
38
  # :reek:NilCheck
39
- def add(content, role: "user", response: nil, image: nil, images: nil, file: nil, files: nil)
39
+ def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
40
40
  if image.nil? && images.nil? && file.nil? && files.nil?
41
- messages.push(
42
- {
43
- role: role,
44
- content: content,
45
- response: response
46
- }.compact
47
- )
48
-
41
+ message = {
42
+ role: role,
43
+ content: content,
44
+ response: response
45
+ }
46
+ message[:content] = content if content
47
+ message[:status] = status if status
48
+ messages.push(message)
49
49
  else
50
50
  text_and_files_array = [
51
51
  {
@@ -78,7 +78,8 @@ module AI
78
78
  messages.push(
79
79
  {
80
80
  role: role,
81
- content: text_and_files_array
81
+ content: text_and_files_array,
82
+ status: status
82
83
  }
83
84
  )
84
85
  end
@@ -92,48 +93,31 @@ module AI
92
93
  add(message, role: "user", image: image, images: images, file: file, files: files)
93
94
  end
94
95
 
95
- def assistant(message, response: nil)
96
- add(message, role: "assistant", response: response)
96
+ def assistant(message, response: nil, status: nil)
97
+ add(message, role: "assistant", response: response, status: status)
97
98
  end
98
99
 
99
100
  # :reek:NilCheck
100
101
  # :reek:TooManyStatements
101
102
  def generate!
102
103
  response = create_response
104
+ parse_response(response)
103
105
 
104
- chat_response = Response.new(response)
105
-
106
- text_response = extract_text_from_response(response)
107
-
108
- image_filenames = extract_and_save_images(response)
109
-
110
- chat_response.images = image_filenames
111
-
112
- message = if schema
113
- if text_response.nil? || text_response.empty?
114
- raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
115
- end
116
- JSON.parse(text_response, symbolize_names: true)
117
- else
118
- text_response
119
- end
106
+ self.previous_response_id = last.dig(:response, :id)
107
+ last
108
+ end
120
109
 
121
- if image_filenames.empty?
122
- assistant(message, response: chat_response)
110
+ # :reek:BooleanParameter
111
+ # :reek:ControlParameter
112
+ # :reek:DuplicateMethodCall
113
+ # :reek:TooManyStatements
114
+ def get_response(wait: false, timeout: 600)
115
+ response = if wait
116
+ wait_for_response(timeout)
123
117
  else
124
- messages.push(
125
- {
126
- role: "assistant",
127
- content: message,
128
- images: image_filenames,
129
- response: chat_response
130
- }.compact
131
- )
118
+ client.responses.retrieve(previous_response_id)
132
119
  end
133
-
134
- self.previous_response_id = response.id
135
-
136
- message
120
+ parse_response(response)
137
121
  end
138
122
 
139
123
  # :reek:NilCheck
@@ -173,6 +157,42 @@ module AI
173
157
  "#<#{self.class.name} @messages=#{messages.inspect} @model=#{@model.inspect} @schema=#{@schema.inspect} @reasoning_effort=#{@reasoning_effort.inspect}>"
174
158
  end
175
159
 
160
+ # Support for Ruby's pp (pretty print)
161
+ # :reek:TooManyStatements
162
+ # :reek:NilCheck
163
+ # :reek:FeatureEnvy
164
+ # :reek:DuplicateMethodCall
165
+ # :reek:UncommunicativeParameterName
166
+ def pretty_print(q)
167
+ q.group(1, "#<#{self.class}", '>') do
168
+ q.breakable
169
+
170
+ # Show messages with truncation
171
+ q.text "@messages="
172
+ truncated_messages = @messages.map do |msg|
173
+ truncated_msg = msg.dup
174
+ if msg[:content].is_a?(String) && msg[:content].length > 80
175
+ truncated_msg[:content] = msg[:content][0..77] + "..."
176
+ end
177
+ truncated_msg
178
+ end
179
+ q.pp truncated_messages
180
+
181
+ # Show other instance variables (except sensitive ones)
182
+ skip_vars = [:@messages, :@api_key, :@client]
183
+ instance_variables.sort.each do |var|
184
+ next if skip_vars.include?(var)
185
+ value = instance_variable_get(var)
186
+ unless value.nil?
187
+ q.text ","
188
+ q.breakable
189
+ q.text "#{var}="
190
+ q.pp value
191
+ end
192
+ end
193
+ end
194
+ end
195
+
176
196
  private
177
197
 
178
198
  class InputClassificationError < StandardError; end
@@ -196,6 +216,7 @@ module AI
196
216
  model: model
197
217
  }
198
218
 
219
+ parameters[:background] = background if background
199
220
  parameters[:tools] = tools unless tools.empty?
200
221
  parameters[:text] = schema if schema
201
222
  parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
@@ -207,10 +228,60 @@ module AI
207
228
  client.responses.create(**parameters)
208
229
  end
209
230
 
231
+ # :reek:NilCheck
232
+ # :reek:TooManyStatements
233
+ def parse_response(response)
234
+ text_response = response.output_text
235
+ image_filenames = extract_and_save_images(response)
236
+ response_id = response.id
237
+ response_usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
238
+
239
+ chat_response = {
240
+ id: response_id,
241
+ model: response.model,
242
+ usage: response_usage,
243
+ total_tokens: response_usage[:total_tokens],
244
+ images: image_filenames
245
+ }.compact
246
+
247
+ response_content = if schema
248
+ if text_response.nil? || text_response.empty?
249
+ raise ArgumentError, "No text content in response to parse as JSON for schema: #{schema.inspect}"
250
+ end
251
+ JSON.parse(text_response, symbolize_names: true)
252
+ else
253
+ text_response
254
+ end
255
+
256
+ existing_message_position = messages.find_index do |message|
257
+ message.dig(:response, :id) == response_id
258
+ end
259
+
260
+ message = {
261
+ role: "assistant",
262
+ content: response_content,
263
+ response: chat_response,
264
+ status: response.status
265
+ }
266
+
267
+ message.store(:images, image_filenames) unless image_filenames.empty?
268
+
269
+ if existing_message_position
270
+ messages[existing_message_position] = message
271
+ else
272
+ messages.push(message)
273
+ message
274
+ end
275
+ end
276
+
277
+ def cancel_request
278
+ client.responses.cancel(previous_response_id)
279
+ end
280
+
210
281
  def prepare_messages_for_api
211
282
  return messages unless previous_response_id
212
283
 
213
- previous_response_index = messages.find_index { |message| message[:response]&.id == previous_response_id }
284
+ previous_response_index = messages.find_index { |message| message.dig(:response, :id) == previous_response_id }
214
285
 
215
286
  if previous_response_index
216
287
  messages[(previous_response_index + 1)..] || []
@@ -354,18 +425,14 @@ module AI
354
425
  if image_generation
355
426
  tools_list << {type: "image_generation"}
356
427
  end
428
+ if code_interpreter
429
+ tools_list << {type: "code_interpreter", container: {type: "auto"}}
430
+ end
357
431
  tools_list
358
432
  end
359
433
 
360
- def extract_text_from_response(response)
361
- response.output.flat_map { |output|
362
- output.respond_to?(:content) ? output.content : []
363
- }.compact.find { |content|
364
- content.is_a?(OpenAI::Models::Responses::ResponseOutputText)
365
- }&.text
366
- end
367
-
368
434
  # :reek:FeatureEnvy
435
+ # :reek:UtilityFunction
369
436
  def wrap_schema_if_needed(schema)
370
437
  if schema.key?(:format) || schema.key?("format")
371
438
  schema
@@ -400,31 +467,129 @@ module AI
400
467
 
401
468
  return image_filenames if image_outputs.empty?
402
469
 
403
- # ISO 8601 basic format with centisecond precision
404
- timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
405
-
406
- subfolder_name = "#{timestamp}_#{response.id}"
407
- subfolder_path = File.join(image_folder || "./images", subfolder_name)
408
- FileUtils.mkdir_p(subfolder_path)
470
+ subfolder_path = create_images_folder(response.id)
409
471
 
410
472
  image_outputs.each_with_index do |output, index|
411
473
  next unless output.respond_to?(:result) && output.result
412
474
 
413
- begin
475
+ warn_if_file_fails_to_save do
414
476
  image_data = Base64.strict_decode64(output.result)
415
477
 
416
478
  filename = "#{(index + 1).to_s.rjust(3, "0")}.png"
417
- filepath = File.join(subfolder_path, filename)
479
+ file_path = File.join(subfolder_path, filename)
418
480
 
419
- File.binwrite(filepath, image_data)
481
+ File.binwrite(file_path, image_data)
420
482
 
421
- image_filenames << filepath
422
- rescue => error
423
- warn "Failed to save image: #{error.message}"
483
+ image_filenames << file_path
424
484
  end
425
485
  end
426
486
 
427
487
  image_filenames
428
488
  end
489
+
490
+ def create_images_folder(response_id)
491
+ # ISO 8601 basic format with centisecond precision
492
+ timestamp = Time.now.strftime("%Y%m%dT%H%M%S%2N")
493
+
494
+ subfolder_name = "#{timestamp}_#{response_id}"
495
+ subfolder_path = File.join(image_folder || "./images", subfolder_name)
496
+ FileUtils.mkdir_p(subfolder_path)
497
+ subfolder_path
498
+ end
499
+
500
+ def warn_if_file_fails_to_save
501
+ begin
502
+ yield
503
+ rescue => error
504
+ warn "Failed to save image: #{error.message}"
505
+ end
506
+ end
507
+
508
+ # :reek:FeatureEnvy
509
+ # :reek:ManualDispatch
510
+ # :reek:NestedIterators
511
+ # :reek:TooManyStatements
512
+ def extract_and_save_files(response)
513
+ filenames = []
514
+
515
+ message_outputs = response.output.select do |output|
516
+ output.respond_to?(:type) && output.type == :message
517
+ end
518
+
519
+ outputs_with_annotations = message_outputs.map do |message|
520
+ message.content.find do |content|
521
+ content.respond_to?(:annotations) && content.annotations.length.positive?
522
+ end
523
+ end.compact
524
+
525
+ return filenames if outputs_with_annotations.empty?
526
+
527
+ subfolder_path = create_images_folder(response.id)
528
+ annotations = outputs_with_annotations.map do |output|
529
+ output.annotations.find do |annotation|
530
+ annotation.respond_to?(:filename)
531
+ end
532
+ end.compact
533
+
534
+ annotations.each do |annotation|
535
+ container_id = annotation.container_id
536
+ file_id = annotation.file_id
537
+ filename = annotation.filename
538
+
539
+ warn_if_file_fails_to_save do
540
+ container_content = client.containers.files.content
541
+ file_content = container_content.retrieve(file_id, container_id: container_id)
542
+ file_path = File.join(subfolder_path, filename)
543
+ File.open(file_path, "wb") do |file|
544
+ file.write(file_content.read)
545
+ end
546
+ filenames << file_path
547
+ end
548
+ end
549
+
550
+ filenames
551
+ end
552
+
553
+ # This is similar to ActiveJob's :polynomially_longer retry option
554
+ # :reek:DuplicateMethodCall
555
+ # :reek:UtilityFunction
556
+ def calculate_wait(executions)
557
+ # cap the maximum wait time to ~110 seconds
558
+ executions = executions.clamp(1..10)
559
+ jitter = 0.15
560
+ ((executions**2) + (Kernel.rand * (executions**2) * jitter)) + 2
561
+ end
562
+
563
+ def timeout_request(duration)
564
+ begin
565
+ Timeout.timeout(duration) do
566
+ yield
567
+ end
568
+ rescue Timeout::Error
569
+ client.responses.cancel(previous_response_id)
570
+ end
571
+ end
572
+
573
+ # :reek:DuplicateMethodCall
574
+ # :reek:TooManyStatements
575
+ def wait_for_response(timeout)
576
+ spinner = TTY::Spinner.new("[:spinner] Thinking ...", format: :dots)
577
+ spinner.auto_spin
578
+ api_response = client.responses.retrieve(previous_response_id)
579
+ number_of_times_polled = 0
580
+ response = timeout_request(timeout) do
581
+ while api_response.status != :completed
582
+ some_amount_of_seconds = calculate_wait(number_of_times_polled)
583
+ sleep some_amount_of_seconds
584
+ number_of_times_polled += 1
585
+ api_response = client.responses.retrieve(previous_response_id)
586
+ end
587
+ api_response
588
+ end
589
+
590
+ exit_message = response.status == :cancelled ? "request timed out" : "done!"
591
+ spinner.stop(exit_message)
592
+ response
593
+ end
429
594
  end
430
595
  end
data/lib/ai-chat.rb CHANGED
@@ -1 +1,8 @@
1
1
  require_relative "ai/chat"
2
+
3
+ # Load amazing_print extension if amazing_print is available
4
+ begin
5
+ require_relative "ai/amazing_print"
6
+ rescue LoadError
7
+ # amazing_print not available, skip custom formatting
8
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raghu Betina
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-09-08 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: openai
@@ -65,6 +66,20 @@ dependencies:
65
66
  - - "~>"
66
67
  - !ruby/object:Gem::Version
67
68
  version: '2.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: tty-spinner
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.9.3
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.9.3
68
83
  - !ruby/object:Gem::Dependency
69
84
  name: dotenv
70
85
  requirement: !ruby/object:Gem::Requirement
@@ -93,20 +108,21 @@ dependencies:
93
108
  - - "~>"
94
109
  - !ruby/object:Gem::Version
95
110
  version: '11.1'
111
+ description:
96
112
  email:
97
113
  - raghu@firstdraft.com
98
114
  executables: []
99
115
  extensions: []
100
116
  extra_rdoc_files:
101
- - LICENSE
102
117
  - README.md
118
+ - LICENSE
103
119
  files:
104
120
  - LICENSE
105
121
  - README.md
106
122
  - ai-chat.gemspec
107
123
  - lib/ai-chat.rb
124
+ - lib/ai/amazing_print.rb
108
125
  - lib/ai/chat.rb
109
- - lib/ai/response.rb
110
126
  homepage: https://github.com/firstdraft/ai-chat
111
127
  licenses:
112
128
  - MIT
@@ -117,6 +133,7 @@ metadata:
117
133
  label: AI Chat
118
134
  rubygems_mfa_required: 'true'
119
135
  source_code_uri: https://github.com/firstdraft/ai-chat
136
+ post_install_message:
120
137
  rdoc_options: []
121
138
  require_paths:
122
139
  - lib
@@ -131,7 +148,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
148
  - !ruby/object:Gem::Version
132
149
  version: '0'
133
150
  requirements: []
134
- rubygems_version: 3.7.1
151
+ rubygems_version: 3.4.6
152
+ signing_key:
135
153
  specification_version: 4
136
154
  summary: A beginner-friendly Ruby interface for OpenAI's API
137
155
  test_files: []
data/lib/ai/response.rb DELETED
@@ -1,17 +0,0 @@
1
- module AI
2
- # :reek:IrresponsibleModule
3
- # :reek:TooManyInstanceVariables
4
- class Response
5
- attr_reader :id, :model, :usage, :total_tokens
6
- # :reek:Attribute
7
- attr_accessor :images
8
-
9
- def initialize(response)
10
- @id = response.id
11
- @model = response.model
12
- @usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
13
- @total_tokens = @usage[:total_tokens]
14
- @images = []
15
- end
16
- end
17
- end