ai-chat 0.0.7 → 0.1.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/ai-chat.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "ai-chat"
5
+ spec.version = "0.1.0"
6
+ spec.authors = ["Raghu Betina"]
7
+ spec.email = ["raghu@firstdraft.com"]
8
+ spec.homepage = "https://github.com/firstdraft/ai-chat"
9
+ spec.summary = "A beginner-friendly Ruby interface for OpenAI's API"
10
+ spec.license = "MIT"
11
+
12
+ spec.metadata = {
13
+ "bug_tracker_uri" => "https://github.com/firstdraft/ai-chat/issues",
14
+ "changelog_uri" => "https://github.com/firstdraft/ai-chat/blob/main/CHANGELOG.md",
15
+ "homepage_uri" => "https://github.com/firstdraft/ai-chat",
16
+ "label" => "AI Chat",
17
+ "rubygems_mfa_required" => "true",
18
+ "source_code_uri" => "https://github.com/firstdraft/ai-chat"
19
+ }
20
+
21
+
22
+ spec.required_ruby_version = "~> 3.2"
23
+ spec.add_dependency "refinements", "~> 11.1"
24
+ spec.add_dependency "zeitwerk", "~> 2.7"
25
+ spec.add_dependency "openai", "~> 0.14"
26
+ spec.add_runtime_dependency "mime-types", "~> 3.0"
27
+ spec.add_runtime_dependency "base64", "~> 0.1" # Works for all Ruby versions
28
+
29
+ spec.add_development_dependency "dotenv"
30
+
31
+ spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
32
+ spec.files = Dir["*.gemspec", "lib/**/*"]
33
+ end
data/lib/ai/chat.rb CHANGED
@@ -1,76 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # All dependencies are now required in the main ai-chat.rb file
3
+ require "base64"
4
+ require "mime-types"
5
+ require "openai"
6
+
7
+ require_relative "response"
4
8
 
5
9
  module AI
10
+ # Main namespace.
6
11
  class Chat
7
- attr_accessor :messages, :schema, :model
8
- attr_reader :reasoning_effort
12
+ def self.loader(registry = Zeitwerk::Registry)
13
+ @loader ||= registry.loaders.each.find { |loader| loader.tag == "ai-chat" }
14
+ end
9
15
 
10
- VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze
16
+ attr_accessor :messages, :schema, :model, :web_search
17
+ attr_reader :reasoning_effort, :client
11
18
 
12
- def initialize(api_key: nil)
13
- @api_key = api_key || ENV.fetch("OPENAI_API_KEY")
19
+ VALID_REASONING_EFFORTS = [:low, :medium, :high].freeze
20
+
21
+ def initialize(api_key: nil, api_key_env_var: "OPENAI_API_KEY")
22
+ @api_key = api_key || ENV.fetch(api_key_env_var)
14
23
  @messages = []
15
- @model = "gpt-4.1-mini"
16
24
  @reasoning_effort = nil
25
+ @model = "gpt-4.1-nano"
26
+ @client = OpenAI::Client.new(api_key: @api_key)
17
27
  end
18
-
19
- def reasoning_effort=(value)
20
- if value.nil?
21
- @reasoning_effort = nil
22
- else
23
- # Convert string to symbol if needed
24
- symbol_value = value.is_a?(String) ? value.to_sym : value
25
-
26
- if VALID_REASONING_EFFORTS.include?(symbol_value)
27
- @reasoning_effort = symbol_value
28
- else
29
- valid_values = VALID_REASONING_EFFORTS.map { |v| ":#{v} or \"#{v}\"" }.join(", ")
30
- raise ArgumentError, "Invalid reasoning_effort value: '#{value}'. Must be one of: #{valid_values}"
31
- end
32
- end
33
- end
34
-
35
- def system(content)
36
- messages.push({role: "system", content: content})
37
- end
38
-
39
- def user(content, image: nil, images: nil)
40
- if content.is_a?(Array)
41
- processed_content = content.map do |item|
42
- if item.key?("image") || item.key?(:image)
43
- image_value = item.fetch("image") { item.fetch(:image) }
44
- {
45
- type: "input_image",
46
- image_url: process_image(image_value)
47
- }
48
- elsif item.key?("text") || item.key?(:text)
49
- text_value = item.fetch("text") { item.fetch(:text) }
50
- {
51
- type: "input_text",
52
- text: text_value
53
- }
54
- else
55
- item
56
- end
57
- end
58
28
 
29
+ def add(content, role: "user", response: nil, image: nil, images: nil, file: nil, files: nil)
30
+ if image.nil? && images.nil? && file.nil? && files.nil?
59
31
  messages.push(
60
32
  {
61
- role: "user",
62
- content: processed_content
63
- }
64
- )
65
- elsif image.nil? && images.nil?
66
- messages.push(
67
- {
68
- role: "user",
69
- content: content
70
- }
33
+ role: role,
34
+ content: content,
35
+ response: response
36
+ }.compact
71
37
  )
38
+
72
39
  else
73
- text_and_images_array = [
40
+ text_and_files_array = [
74
41
  {
75
42
  type: "input_text",
76
43
  text: content
@@ -81,122 +48,133 @@ module AI
81
48
  images_array = images.map do |image|
82
49
  {
83
50
  type: "input_image",
84
- image_url: process_image(image)
51
+ image_url: process_file(image)
85
52
  }
86
53
  end
87
54
 
88
- text_and_images_array += images_array
89
- else
90
- text_and_images_array.push(
55
+ text_and_files_array += images_array
56
+ elsif image
57
+ text_and_files_array.push(
91
58
  {
92
59
  type: "input_image",
93
- image_url: process_image(image)
60
+ image_url: process_file(image)
94
61
  }
95
62
  )
63
+ elsif files && !files.empty?
64
+ files_array = files.map do |file|
65
+ {
66
+ type: "input_file",
67
+ filename: "test",
68
+ file_data: process_file(file)
69
+ }
70
+ end
71
+
72
+ text_and_files_array += files_array
73
+ else
74
+ text_and_files_array.push(
75
+ {
76
+ type: "input_file",
77
+ filename: "test",
78
+ file_data: process_file(file)
79
+ }
80
+ )
81
+
96
82
  end
97
83
 
98
84
  messages.push(
99
85
  {
100
- role: "user",
101
- content: text_and_images_array
86
+ role: role,
87
+ content: text_and_files_array
102
88
  }
103
89
  )
104
90
  end
105
91
  end
106
92
 
107
- def assistant(content)
108
- messages.push({role: "assistant", content: content})
93
+ def system(message)
94
+ add(message, role: "system")
109
95
  end
110
96
 
111
- def assistant!
112
- request_headers_hash = {
113
- "Authorization" => "Bearer #{@api_key}",
114
- "content-type" => "application/json"
115
- }
116
-
117
- request_body_hash = {
118
- "model" => model,
119
- "input" => messages
120
- }
121
-
122
- # Add reasoning parameter if specified
123
- if !@reasoning_effort.nil?
124
- # Convert symbol back to string for the API request
125
- request_body_hash["reasoning"] = {
126
- "effort" => @reasoning_effort.to_s
127
- }
128
- end
97
+ def user(message, image: nil, images: nil, file: nil, files: nil)
98
+ add(message, role: "user", image: image, images: images, file: file, files: files)
99
+ end
100
+
101
+ def assistant(message, response: nil)
102
+ add(message, role: "assistant", response: response)
103
+ end
129
104
 
130
- # Handle structured output (JSON schema)
131
- if !schema.nil?
132
- # Parse the schema and use it with Structured Output (json_schema)
133
- schema_obj = JSON.parse(schema)
134
-
135
- # Extract schema name from the parsed schema, or use a default
136
- schema_name = schema_obj["name"] || "output_object"
137
-
138
- # Responses API uses proper Structured Output with schema
139
- request_body_hash["text"] = {
140
- "format" => {
141
- "type" => "json_schema",
142
- "schema" => schema_obj["schema"] || schema_obj,
143
- "name" => schema_name,
144
- "strict" => true
145
- }
146
- }
105
+ def generate!
106
+ response = create_response
107
+
108
+ if web_search
109
+ message = response.output.last.content.first.text
110
+ chat_response = Response.new(response)
111
+ assistant(message, response: chat_response)
112
+ elsif schema
113
+ # filtering out refusals...
114
+ json_response = response.output.flat_map { _1.content }.select { _1.is_a?(OpenAI::Models::Responses::ResponseOutputText)}.first.text
115
+ chat_response = Response.new(response)
116
+ message = JSON.parse(json_response, symbolize_names: true)
117
+ assistant(message, response: chat_response)
118
+ else
119
+ message = response.output.last.content.first.text
120
+ chat_response = Response.new(response)
121
+ assistant(message, response: chat_response)
147
122
  end
148
123
 
149
- request_body_json = JSON.generate(request_body_hash)
150
-
151
- uri = URI("https://api.openai.com/v1/responses")
152
- raw_response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
153
- request = Net::HTTP::Post.new(uri, request_headers_hash)
154
- request.body = request_body_json
155
- http.request(request)
156
- end
124
+ message
125
+ end
157
126
 
158
- # Handle empty responses or HTTP errors
159
- if raw_response.code.to_i >= 400
160
- raise "HTTP Error #{raw_response.code}: #{raw_response.message}\n#{raw_response.body}"
161
- end
127
+ def pick_up_from(response_id)
128
+ response = client.responses.retrieve(response_id)
129
+ chat_response = Response.new(response)
130
+ message = response.output.flat_map { _1.content }.select { _1.is_a?(OpenAI::Models::Responses::ResponseOutputText)}.first.text
131
+ assistant(message, response: chat_response)
132
+ message
133
+ end
162
134
 
163
- if raw_response.body.nil? || raw_response.body.empty?
164
- raise "Empty response received from OpenAI API"
165
- end
135
+ def reasoning_effort=(value)
136
+ if value.nil?
137
+ @reasoning_effort = nil
138
+ else
139
+ # Convert string to symbol if needed
140
+ symbol_value = value.is_a?(String) ? value.to_sym : value
166
141
 
167
- parsed_response = JSON.parse(raw_response.body)
168
-
169
- # Check for API errors
170
- if parsed_response.key?("error") && parsed_response["error"].is_a?(Hash)
171
- error_message = parsed_response["error"]["message"] || parsed_response["error"].inspect
172
- raise "OpenAI API Error: #{error_message}"
173
- end
174
-
175
- # Extract the text content from the response
176
- content = ""
177
-
178
- # Parse response according to the documented structure
179
- if parsed_response.key?("output") && parsed_response["output"].is_a?(Array) && !parsed_response["output"].empty?
180
- output_item = parsed_response["output"][0]
181
-
182
- if output_item["type"] == "message" && output_item.key?("content")
183
- content_items = output_item["content"]
184
- output_text_item = content_items.find { |item| item["type"] == "output_text" }
185
-
186
- if output_text_item && output_text_item.key?("text")
187
- content = output_text_item["text"]
188
- end
142
+ if VALID_REASONING_EFFORTS.include?(symbol_value)
143
+ @reasoning_effort = symbol_value
144
+ else
145
+ valid_values = VALID_REASONING_EFFORTS.map { |v| ":#{v} or \"#{v}\"" }.join(", ")
146
+ raise ArgumentError, "Invalid reasoning_effort value: '#{value}'. Must be one of: #{valid_values}"
189
147
  end
190
148
  end
191
-
192
- # If no content is found, throw an error
193
- if content.empty?
194
- raise "Failed to extract content from response: #{parsed_response.inspect}"
149
+ end
150
+
151
+ def schema=(value)
152
+ if value.is_a?(String)
153
+ @schema = JSON.parse(value, symbolize_names: true)
154
+ unless @schema.key?(:format) || @schema.key?("format")
155
+ @schema = { format: @schema }
156
+ end
157
+ elsif value.is_a?(Hash)
158
+ if value.key?(:format) || value.key?("format")
159
+ @schema = value
160
+ else
161
+ @schema = { format: value }
162
+ end
163
+ else
164
+ raise ArgumentError, "Invalid schema value: '#{value}'. Must be a String containing JSON or a Hash."
195
165
  end
166
+ end
196
167
 
197
- messages.push({role: "assistant", content: content})
168
+ def last
169
+ messages.last
170
+ end
171
+
172
+ def last_response
173
+ last[:response]
174
+ end
198
175
 
199
- schema.nil? ? content : JSON.parse(content)
176
+ def last_response_id
177
+ last_response&.id
200
178
  end
201
179
 
202
180
  def inspect
@@ -208,6 +186,20 @@ module AI
208
186
  # Custom exception class for input classification errors.
209
187
  class InputClassificationError < StandardError; end
210
188
 
189
+ def create_response
190
+ parameters = {
191
+ model: model,
192
+ input: strip_responses(messages),
193
+ tools: tools,
194
+ text: schema,
195
+ reasoning: {
196
+ effort: reasoning_effort
197
+ }.compact
198
+ }.compact
199
+ parameters = parameters.delete_if { |k, v| v.empty? }
200
+ client.responses.create(**parameters)
201
+ end
202
+
211
203
  def classify_obj(obj)
212
204
  if obj.is_a?(String)
213
205
  # Attempt to parse as a URL.
@@ -236,7 +228,7 @@ module AI
236
228
  end
237
229
  end
238
230
 
239
- def process_image(obj)
231
+ def process_file(obj)
240
232
  case classify_obj(obj)
241
233
  when :url
242
234
  obj
@@ -262,13 +254,31 @@ module AI
262
254
  mime_type = MIME::Types.type_for(filename).first.to_s
263
255
  mime_type = "image/jpeg" if mime_type.empty?
264
256
 
265
- image_data = obj.read
257
+ file_data = obj.read
266
258
  obj.rewind if obj.respond_to?(:rewind)
267
259
 
268
- base64_string = Base64.strict_encode64(image_data)
260
+ base64_string = Base64.strict_encode64(file_data)
269
261
 
270
262
  "data:#{mime_type};base64,#{base64_string}"
271
263
  end
272
264
  end
265
+
266
+ def strip_responses(messages)
267
+ messages.each do |message|
268
+ message.delete(:response) if message.key?(:response)
269
+ message[:content] = JSON.generate(message[:content]) if message[:content].is_a?(Hash)
270
+ end
271
+ end
272
+
273
+ def tools
274
+ tools_list = []
275
+ if web_search
276
+ tools_list << { type: "web_search_preview" }
277
+ end
278
+ end
279
+
280
+ def extract_message(response)
281
+ response.output.flat_map { _1.content }.select { _1.is_a?(OpenAI::Models::Responses::ResponseOutputText)}.first.text
282
+ end
273
283
  end
274
284
  end
@@ -0,0 +1,12 @@
1
+ module AI
2
+ class Response
3
+ attr_reader :id, :model, :usage, :total_tokens
4
+
5
+ def initialize(response)
6
+ @id = response.id
7
+ @model = response.model
8
+ @usage = response.usage.to_h.slice(:input_tokens, :output_tokens, :total_tokens)
9
+ @total_tokens = @usage[:total_tokens]
10
+ end
11
+ end
12
+ end
data/lib/ai-chat.rb CHANGED
@@ -1,11 +1 @@
1
- # All gem dependencies
2
- require "mime/types"
3
- require "base64"
4
- require "json"
5
- require "net/http"
6
- require "pathname"
7
- require "uri"
8
-
9
- # Internal gem files
10
- require "ai/chat/version"
11
- require "ai/chat"
1
+ require_relative "ai/chat"
data/lib/ai_chat.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "zeitwerk"
2
+ loader = Zeitwerk::Loader.for_gem
3
+ loader.inflector.inflect("ai" => "AI")
4
+ loader.setup
5
+
6
+ require_relative "ai/chat"
metadata CHANGED
@@ -1,174 +1,142 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raghu Betina
8
- - Jelani Woods
9
- bindir: exe
8
+ autorequire:
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-26 00:00:00.000000000 Z
11
+ date: 2025-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: mime-types
14
+ name: refinements
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '3.0'
19
+ version: '11.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '3.0'
27
- - !ruby/object:Gem::Dependency
28
- name: base64
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: rake
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '13.0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '13.0'
26
+ version: '11.1'
55
27
  - !ruby/object:Gem::Dependency
56
- name: rspec
28
+ name: zeitwerk
57
29
  requirement: !ruby/object:Gem::Requirement
58
30
  requirements:
59
31
  - - "~>"
60
32
  - !ruby/object:Gem::Version
61
- version: '3.12'
62
- type: :development
33
+ version: '2.7'
34
+ type: :runtime
63
35
  prerelease: false
64
36
  version_requirements: !ruby/object:Gem::Requirement
65
37
  requirements:
66
38
  - - "~>"
67
39
  - !ruby/object:Gem::Version
68
- version: '3.12'
40
+ version: '2.7'
69
41
  - !ruby/object:Gem::Dependency
70
- name: factory_bot
42
+ name: openai
71
43
  requirement: !ruby/object:Gem::Requirement
72
44
  requirements:
73
45
  - - "~>"
74
46
  - !ruby/object:Gem::Version
75
- version: '6.2'
76
- type: :development
47
+ version: '0.14'
48
+ type: :runtime
77
49
  prerelease: false
78
50
  version_requirements: !ruby/object:Gem::Requirement
79
51
  requirements:
80
52
  - - "~>"
81
53
  - !ruby/object:Gem::Version
82
- version: '6.2'
54
+ version: '0.14'
83
55
  - !ruby/object:Gem::Dependency
84
- name: webmock
56
+ name: mime-types
85
57
  requirement: !ruby/object:Gem::Requirement
86
58
  requirements:
87
59
  - - "~>"
88
60
  - !ruby/object:Gem::Version
89
- version: '3.18'
90
- type: :development
61
+ version: '3.0'
62
+ type: :runtime
91
63
  prerelease: false
92
64
  version_requirements: !ruby/object:Gem::Requirement
93
65
  requirements:
94
66
  - - "~>"
95
67
  - !ruby/object:Gem::Version
96
- version: '3.18'
68
+ version: '3.0'
97
69
  - !ruby/object:Gem::Dependency
98
- name: vcr
70
+ name: base64
99
71
  requirement: !ruby/object:Gem::Requirement
100
72
  requirements:
101
73
  - - "~>"
102
74
  - !ruby/object:Gem::Version
103
- version: '6.1'
104
- type: :development
75
+ version: '0.1'
76
+ type: :runtime
105
77
  prerelease: false
106
78
  version_requirements: !ruby/object:Gem::Requirement
107
79
  requirements:
108
80
  - - "~>"
109
81
  - !ruby/object:Gem::Version
110
- version: '6.1'
82
+ version: '0.1'
111
83
  - !ruby/object:Gem::Dependency
112
- name: standard
84
+ name: dotenv
113
85
  requirement: !ruby/object:Gem::Requirement
114
86
  requirements:
115
- - - "~>"
87
+ - - ">="
116
88
  - !ruby/object:Gem::Version
117
- version: '1.32'
89
+ version: '0'
118
90
  type: :development
119
91
  prerelease: false
120
92
  version_requirements: !ruby/object:Gem::Requirement
121
93
  requirements:
122
- - - "~>"
94
+ - - ">="
123
95
  - !ruby/object:Gem::Version
124
- version: '1.32'
125
- description: This gem provides a class called `AI::Chat` that is intended to make
126
- it as easy as possible to use OpenAI's Responses API. Supports Structured Output
127
- and Image Processing.
96
+ version: '0'
97
+ description:
128
98
  email:
129
99
  - raghu@firstdraft.com
130
- - jelani@firstdraft.com
131
100
  executables: []
132
101
  extensions: []
133
- extra_rdoc_files: []
102
+ extra_rdoc_files:
103
+ - README.md
104
+ - LICENSE
134
105
  files:
135
- - ".config/rubocop/config.yml"
136
- - ".reek.yml"
137
- - ".ruby-version"
138
- - CHANGELOG.md
139
- - CODE_OF_CONDUCT.md
140
- - Gemfile
141
- - LICENSE.txt
106
+ - LICENSE
142
107
  - README.md
143
- - Rakefile
108
+ - ai-chat.gemspec
144
109
  - lib/ai-chat.rb
145
110
  - lib/ai/chat.rb
146
- - lib/ai/chat/version.rb
147
- - test_program/Gemfile
148
- - test_program/test_ai_chat.rb
111
+ - lib/ai/response.rb
112
+ - lib/ai_chat.rb
149
113
  homepage: https://github.com/firstdraft/ai-chat
150
114
  licenses:
151
115
  - MIT
152
116
  metadata:
117
+ bug_tracker_uri: https://github.com/firstdraft/ai-chat/issues
118
+ changelog_uri: https://github.com/firstdraft/ai-chat/blob/main/CHANGELOG.md
153
119
  homepage_uri: https://github.com/firstdraft/ai-chat
120
+ label: AI Chat
121
+ rubygems_mfa_required: 'true'
154
122
  source_code_uri: https://github.com/firstdraft/ai-chat
155
- changelog_uri: https://github.com/firstdraft/ai-chat/blob/main/CHANGELOG.md
123
+ post_install_message:
156
124
  rdoc_options: []
157
125
  require_paths:
158
126
  - lib
159
127
  required_ruby_version: !ruby/object:Gem::Requirement
160
128
  requirements:
161
- - - ">="
129
+ - - "~>"
162
130
  - !ruby/object:Gem::Version
163
- version: 2.0.0
131
+ version: '3.2'
164
132
  required_rubygems_version: !ruby/object:Gem::Requirement
165
133
  requirements:
166
134
  - - ">="
167
135
  - !ruby/object:Gem::Version
168
136
  version: '0'
169
137
  requirements: []
170
- rubygems_version: 3.6.5
138
+ rubygems_version: 3.5.23
139
+ signing_key:
171
140
  specification_version: 4
172
- summary: This gem provides a class called `AI::Chat` that is intended to make it as
173
- easy as possible to use OpenAI's Responses API.
141
+ summary: A beginner-friendly Ruby interface for OpenAI's API
174
142
  test_files: []
@@ -1,2 +0,0 @@
1
- inherit_gem:
2
- caliber: config/all.yml