open_ai_bot 0.3.0 → 0.3.1

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: 33e8660fa731258c76bc1f3367fe02f866a02a189d49444d0006b791f49df6e4
4
- data.tar.gz: fff2721f65eb41a74c9dc7569bc1be202ed3bd952c787fd371db158662542da8
3
+ metadata.gz: 21fff768a394b10773c77b9fafea42d01500cca136aea8c418753da1ad61024d
4
+ data.tar.gz: 7b2da8e1e8e0038a5420877ed0644888c0948976876dc065f65bb0f1ba9f94f1
5
5
  SHA512:
6
- metadata.gz: 84ee567855c908efb78b5b4dc8133a35a954eb8b0d21ad0889518037eb3eed5fea210cdcb1a0f948f0b4fa323e0b92da0e5c5a6bfcf6af4d5d64bac796f09f7a
7
- data.tar.gz: 2eab3c94fc4493ce5f02e62ca909cf9edbe24955ea4b3085dab3971f4449f958f61939f4d54e3beb02755f9f1977e1950bb588e6be8e7898e8f88224d3684561
6
+ metadata.gz: 82fff5e67a384cbcd0cb6d8a99bb1a3c9a42016ced7dbe9bd2d93d694183ab299f5b9b308662660c3997aa3d6407e420151e28e019ed9d63c1a8716b50bb3b6e
7
+ data.tar.gz: e525381d25b1e2d81e3bbd73c6c6990753948d0287c476f531fca32f16e3980b0f8380869945e9254e639925a6247ac799a140442899496d74687554631370e2
data/.rubocop.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  require: rubocop-rspec
2
2
 
3
3
  AllCops:
4
- TargetRubyVersion: 3.2.2
4
+ TargetRubyVersion: 3.3.2
5
5
  NewCops: enable
6
6
  Exclude:
7
7
  - test.rb
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.2
data/Gemfile.lock CHANGED
@@ -3,17 +3,17 @@ GEM
3
3
  specs:
4
4
  addressable (2.8.6)
5
5
  public_suffix (>= 2.0.2, < 6.0)
6
- async (2.6.5)
7
- console (~> 1.10)
6
+ async (2.12.0)
7
+ console (~> 1.25, >= 1.25.2)
8
8
  fiber-annotation
9
- io-event (~> 1.1)
10
- timers (~> 4.1)
11
- base64 (0.2.0)
12
- concurrent-ruby (1.2.2)
13
- console (1.23.3)
9
+ io-event (~> 1.6)
10
+ bigdecimal (3.1.8)
11
+ concurrent-ruby (1.3.2)
12
+ console (1.25.2)
14
13
  fiber-annotation
15
- fiber-local
16
- down (5.4.1)
14
+ fiber-local (~> 1.1)
15
+ json
16
+ down (5.4.2)
17
17
  addressable (~> 2.8)
18
18
  dry-core (1.0.1)
19
19
  concurrent-ruby (~> 1.0)
@@ -28,31 +28,35 @@ GEM
28
28
  dry-types (>= 1.7, < 2)
29
29
  ice_nine (~> 0.11)
30
30
  zeitwerk (~> 2.6)
31
- dry-types (1.7.1)
31
+ dry-types (1.7.2)
32
+ bigdecimal (~> 3.0)
32
33
  concurrent-ruby (~> 1.0)
33
34
  dry-core (~> 1.0)
34
35
  dry-inflector (~> 1.0)
35
36
  dry-logic (~> 1.4)
36
37
  zeitwerk (~> 2.6)
37
38
  event_stream_parser (0.3.0)
38
- faraday (2.7.12)
39
- base64
40
- faraday-net_http (>= 2.0, < 3.1)
41
- ruby2_keywords (>= 0.0.4)
39
+ faraday (2.9.1)
40
+ faraday-net_http (>= 2.0, < 3.2)
42
41
  faraday-multipart (1.0.4)
43
42
  multipart-post (~> 2)
44
- faraday-net_http (3.0.2)
43
+ faraday-net_http (3.1.0)
44
+ net-http
45
45
  fiber-annotation (0.2.0)
46
- fiber-local (1.0.0)
46
+ fiber-local (1.1.0)
47
+ fiber-storage
48
+ fiber-storage (0.1.1)
47
49
  ice_nine (0.11.2)
48
- io-event (1.3.3)
49
- multipart-post (2.3.0)
50
- public_suffix (5.0.4)
50
+ io-event (1.6.0)
51
+ json (2.7.2)
52
+ multipart-post (2.4.1)
53
+ net-http (0.4.1)
54
+ uri
55
+ public_suffix (5.0.5)
51
56
  ruby-openai (5.2.0)
52
57
  event_stream_parser (>= 0.3.0, < 1.0.0)
53
58
  faraday (>= 1)
54
59
  faraday-multipart (>= 1)
55
- ruby2_keywords (0.0.5)
56
60
  rubydium (0.4.1)
57
61
  async (~> 2.3)
58
62
  telegram-bot-ruby (~> 1.0.0)
@@ -61,10 +65,11 @@ GEM
61
65
  faraday (~> 2.0)
62
66
  faraday-multipart (~> 1.0)
63
67
  zeitwerk (~> 2.6)
64
- timers (4.3.5)
65
- zeitwerk (2.6.12)
68
+ uri (0.13.0)
69
+ zeitwerk (2.6.15)
66
70
 
67
71
  PLATFORMS
72
+ ruby
68
73
  x86_64-linux
69
74
 
70
75
  DEPENDENCIES
@@ -73,4 +78,4 @@ DEPENDENCIES
73
78
  rubydium (>= 0.2.5)
74
79
 
75
80
  BUNDLED WITH
76
- 2.4.19
81
+ 2.5.9
data/README.md CHANGED
@@ -16,8 +16,8 @@ sudo apt install autoconf patch build-essential rustc libssl-dev libyaml-dev lib
16
16
  git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.13.1
17
17
  echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
18
18
  asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git
19
- asdf install ruby 3.2.2
20
- echo 'ruby 3.2.2' >> ~/.tool-versions
19
+ asdf install ruby 3.3.2
20
+ echo 'ruby 3.3.2' >> ~/.tool-versions
21
21
  ```
22
22
 
23
23
  2. `ffmpeg`
@@ -72,16 +72,6 @@ module OpenAI
72
72
  "NULL"
73
73
  end
74
74
 
75
- def base64(file)
76
- return unless config.open_ai["chat_gpt_model"] == "gpt-4-vision-preview"
77
- return unless file
78
-
79
- f = download_file(file)
80
- res = Base64.encode64(f.read)
81
- FileUtils.rm_rf("./#{f.original_filename}")
82
- res
83
- end
84
-
85
75
  def handle_gpt_command
86
76
  return unless bot_mentioned? || bot_replied_to? || private_chat?
87
77
  return if self.class.registered_commands.keys.any? { @text.include? _1 }
@@ -97,7 +87,8 @@ module OpenAI
97
87
  from: username(@user),
98
88
  body: @text_without_bot_mentions,
99
89
  chat_id: @chat.id,
100
- base64_image: base64(@msg.photo&.last)
90
+ chat_thread: current_thread,
91
+ image: Image.from_tg_photo(download_file(@msg.photo&.last), model: current_thread.model)
101
92
  )
102
93
 
103
94
  return unless current_message.valid?
@@ -110,7 +101,8 @@ module OpenAI
110
101
  from: username(@target),
111
102
  body: @replies_to.text.to_s.gsub(/@#{config.bot_username}\b/, ""),
112
103
  chat_id: @chat.id,
113
- base64_image: base64(@replies_to.photo&.last)
104
+ chat_thread: current_thread,
105
+ image: Image.from_tg_photo(download_file(@replies_to.photo&.last), model: current_thread.model)
114
106
  )
115
107
  else
116
108
  nil
@@ -127,7 +119,7 @@ module OpenAI
127
119
 
128
120
  response = open_ai.chat(
129
121
  parameters: {
130
- model: current_thread.model || config.open_ai["chat_gpt_model"],
122
+ model: current_thread.model.to_s,
131
123
  messages: current_thread.as_json
132
124
  }
133
125
  )
@@ -138,9 +130,10 @@ module OpenAI
138
130
  send_chat_gpt_error(error_text.strip)
139
131
  else
140
132
  text = response.dig("choices", 0, "message", "content")
141
- tokens = response.dig("usage", "total_tokens")
142
133
 
143
- send_chat_gpt_response(text, tokens)
134
+ tokens_info = get_tokens_info!(response)
135
+
136
+ send_chat_gpt_response(text, tokens_info)
144
137
  end
145
138
  end
146
139
 
@@ -148,15 +141,28 @@ module OpenAI
148
141
  reply(text, parse_mode: "Markdown")
149
142
  end
150
143
 
151
- def send_chat_gpt_response(text, tokens)
152
- id = reply(text).dig("result", "message_id")
144
+ def get_tokens_info!(response)
145
+ completion_tokens = response.dig("usage", "completion_tokens")
146
+ prompt_tokens = response.dig("usage", "prompt_tokens")
147
+ vision_tokens = current_thread.claim_vision_tokens!
148
+
149
+ result = current_thread.model.request_cost(completion_tokens:, prompt_tokens:, vision_tokens:, current_thread:)
150
+ end
151
+
152
+ def send_chat_gpt_response(text, tokens_info)
153
+ tokens_text = tokens_info[:info]
154
+
155
+ id = reply(text + tokens_text).dig("result", "message_id")
156
+
153
157
  bot_message = BotMessage.new(
154
158
  id: id,
155
159
  replies_to: @message_id,
156
160
  body: text,
157
161
  chat_id: @chat.id,
158
- tokens: tokens
162
+ chat_thread: current_thread,
163
+ cost: tokens_info[:total]
159
164
  )
165
+
160
166
  current_thread.add(bot_message)
161
167
  end
162
168
  end
@@ -3,8 +3,9 @@
3
3
  module OpenAI
4
4
  class ChatThread
5
5
  def initialize(defaults = [], model = nil)
6
+ model ||= OpenAIBot.config.open_ai["chat_gpt_model"].to_sym
6
7
  @history ||= defaults
7
- @model = model
8
+ @model = model.is_a?(Model) ? model : Model.new(model)
8
9
  puts @history
9
10
  end
10
11
 
@@ -20,6 +21,14 @@ module OpenAI
20
21
  true
21
22
  end
22
23
 
24
+ def total_cost
25
+ @history.map(&:cost).compact.sum
26
+ end
27
+
28
+ def claim_vision_tokens!
29
+ @history.reject(&:vision_tokens_claimed?).map(&:claim_vision_tokens!).compact.sum
30
+ end
31
+
23
32
  def add(message)
24
33
  return false unless message&.valid?
25
34
  return false if @history.any? { message.id == _1.id}
@@ -0,0 +1,38 @@
1
+ module OpenAI
2
+ class Image
3
+ BASE_TOKENS = 85
4
+ TILE_TOKENS = 170
5
+ TILE_SIZE = 512
6
+
7
+ def self.from_tg_photo(file, model:)
8
+ return unless file
9
+ return unless model.has_vision?
10
+
11
+ base64 = Base64.encode64(file.read)
12
+ size = `identify -format "%w %h" ./#{file.original_filename}`
13
+ width, height = size.split(" ")
14
+ FileUtils.rm_rf("./#{file.original_filename}")
15
+
16
+ new(width, height, base64)
17
+ end
18
+
19
+ attr_accessor :width, :height, :base64
20
+
21
+ def initialize(width, height, base64)
22
+ @width = width
23
+ @height = height
24
+ @base64 = base64
25
+ end
26
+
27
+ def tokens
28
+ @tokens ||= begin
29
+ tiles = tiles(width) * tiles(height)
30
+ (tiles * TILE_TOKENS) + BASE_TOKENS
31
+ end
32
+ end
33
+
34
+ def tiles(pixels)
35
+ (pixels.to_f / TILE_SIZE).ceil
36
+ end
37
+ end
38
+ end
@@ -3,29 +3,40 @@ module OpenAI
3
3
  # (ChatGPT isn't brilliant at parsing JSON sructures without starting to reply in JSON, so most of it is useless)
4
4
 
5
5
  class Message
6
- attr_accessor :body, :from, :id, :replies_to, :tokens, :chat_id, :base64_image
6
+ attr_accessor :body, :from, :id, :replies_to, :chat_id, :image, :chat_thread, :cost
7
7
  attr_reader :role, :timestamp
8
8
 
9
9
  def initialize(**kwargs)
10
10
  kwargs.each_pair { public_send("#{_1}=", _2) }
11
11
  @role = :user
12
12
  @timestamp = Time.now.to_i
13
+ @vision_tokens_claimed = !image
14
+ end
15
+
16
+ def vision_tokens_claimed?
17
+ @vision_tokens_claimed
18
+ end
19
+
20
+ def claim_vision_tokens!
21
+ # binding.pry
22
+ @vision_tokens_claimed = true
23
+ image&.tokens
13
24
  end
14
25
 
15
26
  def valid?
16
- [(base64_image || body), from, id, chat_id].all?(&:present?)
27
+ [(image || body), from, id, chat_id, chat_thread].all?(&:present?)
17
28
  end
18
29
 
19
30
  # Format for OpenAI API
20
31
  def as_json
21
32
  msg = [from, body].compact.join("\n")
22
33
 
23
- if base64_image
34
+ if image
24
35
  {
25
36
  role: role,
26
37
  content: [
27
38
  { type: "text", text: msg },
28
- { type: "image_url", image_url: { url: "data:image/jpeg;base64,#{base64_image}" } }
39
+ { type: "image_url", image_url: { url: "data:image/jpeg;base64,#{image.base64}" } }
29
40
  ]
30
41
  }
31
42
  else
@@ -46,8 +57,8 @@ module OpenAI
46
57
  "From" => from,
47
58
  "To" => replies_to,
48
59
  "Body" => body,
49
- "Tokens used" => tokens,
50
- "Image" => (base64_image ? "Some image" : "None")
60
+ # "Tokens used" => tokens,
61
+ "Image" => (image ? "Some image" : "None")
51
62
  }.reject { |_k, v|
52
63
  v.blank?
53
64
  }.map { |k, v|
@@ -69,7 +80,7 @@ module OpenAI
69
80
  end
70
81
 
71
82
  def valid?
72
- body.present?
83
+ [body, chat_thread].all?(&:present?)
73
84
  end
74
85
  end
75
86
 
@@ -80,7 +91,7 @@ module OpenAI
80
91
  end
81
92
 
82
93
  def valid?
83
- [body, id, chat_id, tokens].all?(&:present?)
94
+ [body, id, chat_id, chat_thread].all?(&:present?)
84
95
  end
85
96
  end
86
97
  end
@@ -0,0 +1,63 @@
1
+ module OpenAI
2
+ class Model
3
+ # All prices are per 1K tokens
4
+ MODEL_INFO = {
5
+ "gpt-4o": {
6
+ max_context: 128_000,
7
+ prompt_price: 0.005,
8
+ completion_price: 0.015,
9
+ vision_price: 0.005
10
+ },
11
+ "gpt-3.5-turbo": {
12
+ max_context: 16385,
13
+ prompt_price: 0.0005,
14
+ completion_price: 0.0015,
15
+ vision_price: 0
16
+ }
17
+ }
18
+
19
+ attr_accessor :max_context, :prompt_price, :completion_price, :vision_price
20
+
21
+ [:max_context, :prompt_price, :completion_price, :vision_price].each do |attr|
22
+ define_method(attr) do
23
+ MODEL_INFO[@model][attr]
24
+ end
25
+ end
26
+
27
+ def initialize(model)
28
+ raise ArgumentError.new("Unknown model: #{model}") unless MODEL_INFO[model]
29
+
30
+ @model = model
31
+ end
32
+
33
+ def to_s
34
+ @model
35
+ end
36
+
37
+ def has_vision?
38
+ MODEL_INFO[@model][:vision_price].positive?
39
+ end
40
+
41
+ def request_cost(prompt_tokens:, completion_tokens:, vision_tokens:, current_thread:)
42
+ prompt_cost = prompt_tokens * prompt_price / 1000
43
+ completion_cost = completion_tokens * completion_price / 1000
44
+ vision_cost = vision_tokens * vision_price / 1000
45
+
46
+ total = prompt_cost + completion_cost + vision_cost
47
+ thread_total = current_thread.total_cost
48
+
49
+ info = "\n\n" + {
50
+ prompt: "#{prompt_tokens} tokens (#{prompt_cost.round(5)}$)",
51
+ completion: "#{completion_tokens} tokens (#{completion_cost.round(5)}$)",
52
+ vision: "#{vision_tokens} tokens (#{vision_cost.round(5)}$)",
53
+ total: "#{total.round(5)}$",
54
+ total_for_this_conversation: "#{(thread_total + total).round(5)}$",
55
+ max_context: max_context
56
+ }.map { |k, v|
57
+ "#{k}: #{v}"
58
+ }.join("\n")
59
+
60
+ { info:, total: }
61
+ end
62
+ end
63
+ end
data/lib/open_ai/utils.rb CHANGED
@@ -15,6 +15,8 @@ module OpenAI
15
15
  end
16
16
 
17
17
  def download_file(voice, dir=nil)
18
+ return unless voice
19
+
18
20
  file_path = @api.get_file(file_id: voice.file_id)["result"]["file_path"]
19
21
 
20
22
  url = "https://api.telegram.org/file/bot#{config.token}/#{file_path}"
data/lib/open_ai_bot.rb CHANGED
@@ -6,11 +6,12 @@ require_relative "open_ai/message"
6
6
  require_relative "open_ai/dalle"
7
7
  require_relative "open_ai/utils"
8
8
  require_relative "open_ai/whisper"
9
+ require_relative "open_ai/model"
10
+ require_relative "open_ai/image"
9
11
 
10
12
  require_relative "ext/blank"
11
13
  require_relative "ext/in"
12
14
 
13
-
14
15
  class OpenAIBot < Rubydium::Bot
15
16
  include OpenAI::ChatGPT
16
17
  include OpenAI::Dalle
data/main.rb CHANGED
@@ -5,6 +5,7 @@ require "openai"
5
5
  require "yaml"
6
6
  require "down"
7
7
  require "rubydium"
8
+ require "base64"
8
9
 
9
10
  require_relative "lib/open_ai_bot"
10
11
  require_relative "lib/clean_bot"
data/open_ai_bot.gemspec CHANGED
@@ -8,7 +8,7 @@ require_relative "lib/ext/in"
8
8
 
9
9
  Gem::Specification.new do |spec|
10
10
  spec.name = "open_ai_bot"
11
- spec.version = "0.3.0"
11
+ spec.version = "0.3.1"
12
12
  spec.authors = ["bulgakke"]
13
13
  spec.email = ["vvp835@yandex.ru"]
14
14
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_ai_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - bulgakke
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-12-09 00:00:00.000000000 Z
11
+ date: 2024-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: down
@@ -80,7 +80,7 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '5.1'
83
- description:
83
+ description:
84
84
  email:
85
85
  - vvp835@yandex.ru
86
86
  executables: []
@@ -89,6 +89,7 @@ extra_rdoc_files: []
89
89
  files:
90
90
  - ".gitignore"
91
91
  - ".rubocop.yml"
92
+ - ".ruby-version"
92
93
  - Gemfile
93
94
  - Gemfile.lock
94
95
  - README.md
@@ -100,7 +101,9 @@ files:
100
101
  - lib/open_ai/chat_gpt.rb
101
102
  - lib/open_ai/chat_thread.rb
102
103
  - lib/open_ai/dalle.rb
104
+ - lib/open_ai/image.rb
103
105
  - lib/open_ai/message.rb
106
+ - lib/open_ai/model.rb
104
107
  - lib/open_ai/utils.rb
105
108
  - lib/open_ai/whisper.rb
106
109
  - lib/open_ai_bot.rb
@@ -113,7 +116,7 @@ metadata:
113
116
  homepage_uri: https://github.com/bulgakke/open_ai_bot
114
117
  source_code_uri: https://github.com/bulgakke/open_ai_bot
115
118
  rubygems_mfa_required: 'true'
116
- post_install_message:
119
+ post_install_message:
117
120
  rdoc_options: []
118
121
  require_paths:
119
122
  - lib
@@ -128,8 +131,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
131
  - !ruby/object:Gem::Version
129
132
  version: '0'
130
133
  requirements: []
131
- rubygems_version: 3.4.10
132
- signing_key:
134
+ rubygems_version: 3.5.9
135
+ signing_key:
133
136
  specification_version: 4
134
137
  summary: Telegram bot for using ChatGPT, DALL-E and Whisper
135
138
  test_files: []