ai-engine 0.0.1 → 0.3.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +55 -7
  3. data/Rakefile +7 -1
  4. data/app/models/ai/engine/application_record.rb +7 -0
  5. data/app/models/ai/engine/assistant.rb +15 -0
  6. data/app/models/ai/engine/assistant_thread.rb +32 -0
  7. data/app/models/ai/engine/chat.rb +45 -0
  8. data/app/models/ai/engine/message.rb +73 -0
  9. data/app/models/ai/engine/run.rb +58 -0
  10. data/app/models/concerns/ai/engine/assistable.rb +48 -0
  11. data/app/models/concerns/ai/engine/chattable.rb +23 -0
  12. data/app/models/concerns/ai/engine/remote_id_validatable.rb +32 -0
  13. data/app/models/concerns/ai/engine/threadable.rb +23 -0
  14. data/app/services/ai/engine/openai/assistants/create.rb +25 -0
  15. data/app/services/ai/engine/openai/assistants/retrieve.rb +15 -0
  16. data/app/services/ai/engine/openai/assistants/update.rb +25 -0
  17. data/app/services/ai/engine/openai/chats/stream.rb +24 -0
  18. data/app/services/ai/engine/openai/messages/create.rb +23 -0
  19. data/app/services/ai/engine/openai/runs/create.rb +21 -0
  20. data/app/services/ai/engine/openai/runs/retrieve.rb +15 -0
  21. data/app/services/ai/engine/openai/threads/create.rb +17 -0
  22. data/config/initializers/inflections.rb +4 -0
  23. data/config/initializers/openai.rb +1 -0
  24. data/db/migrate/20240528153439_create_ai_engine_assistants.rb +12 -0
  25. data/db/migrate/20240530121855_create_ai_engine_assistant_threads.rb +10 -0
  26. data/db/migrate/20240530130834_create_ai_engine_runs.rb +11 -0
  27. data/db/migrate/20240530135629_create_ai_engine_messages.rb +16 -0
  28. data/db/migrate/20240619150010_create_ai_engine_chats.rb +9 -0
  29. data/lib/ai/engine/engine.rb +58 -0
  30. data/lib/ai/engine/version.rb +2 -2
  31. data/lib/ai/engine.rb +2 -1
  32. metadata +42 -35
  33. data/.gitignore +0 -9
  34. data/Gemfile +0 -4
  35. data/LICENSE.txt +0 -21
  36. data/ai-engine.gemspec +0 -37
  37. data/bin/console +0 -14
  38. data/bin/setup +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89ab47c148d5272931c77599504db1420eb055f51d66f090d2784f644cdb5c69
4
- data.tar.gz: 642226bca65ab4c69598f0e4fb06557d17c9c9e41bde0588ff91ed6006e58f93
3
+ metadata.gz: 243780cd32368a63bf16fef0f89a122c2877d338fa196c885925aee25161e060
4
+ data.tar.gz: a41343a78d3bfe8098c4fa8b169dbc85c789351ba710f16da927ca7b609b654b
5
5
  SHA512:
6
- metadata.gz: c92718c7dbba980c2bc83bc813e8e8ddd4a7679f05a0632bacf913bdcf60afccf73d33a66effec8db920721ed9f146aa1db89bdb1b1b0fe526b6ddabece0b5c1
7
- data.tar.gz: 182803f6b450d3b7cf205f6f7eccd5a3f45f99dd2766bd970d0e491199de8187b7ddbf15e493ee4e694dfb5ced7da68b78c0adbf08890ea22801ab7839e9e5aa
6
+ metadata.gz: ab48e0f806a2b4171e16b567c819c6d759cd453b525bc9d4152b689ab195922fc7eafc691e222cad13f05682226a5e8a9e836c11da610062378cf24478116acf
7
+ data.tar.gz: 25826ebd894a27f9b0599e2fde6586acc76bc496de3ec9a98d0f1a3178fac780ac11358dbe9735dcd0e9d892aa27bc22199eaed3713c744a1fae7f693f5619b6
data/README.md CHANGED
@@ -1,13 +1,61 @@
1
- # (Placeholder) AI::Engine
1
+ # AI::Engine
2
2
 
3
- AI::Engine is a private gem - this placeholder is registered to avoid a bad actor making a public gem with the same name, which could contain malicious code.
3
+ An experimental, easy-ish way to add AI assistants to your Rails app!
4
4
 
5
+ ## Usage
6
+
7
+ You can add AI::Engine to your Gemfile like this:
8
+
9
+ ```
10
+ gem "ai-engine", "~> 0.3.0"
11
+ ```
12
+
13
+ You then need to add the migrations for the gem:
14
+
15
+ ```
16
+ bundle exec rails ai_engine:install:migrations
5
17
  ```
6
- source 'AI::Engine source' do
7
- gem 'ai-engine'
8
- end
18
+
19
+ And run them:
20
+
21
+ ```
22
+ bundle exec rails db:migrate
23
+ ```
24
+
25
+ Full usage documentation can be found at [RailsAI.com](https://railsai.com/docs/installation).
26
+
27
+ ## Engine Development
28
+
29
+ ### Test local version in a Rails app
30
+
31
+ ```bash
32
+ gem "ai-engine", path: "../ai-engine"
9
33
  ```
10
34
 
11
- You must have the source command above to prevent downloading this placeholder.
35
+ ### ENV
36
+
37
+ The dummy app needs a .env file in the root of the engine for manual and RSpec testing - see .env.example.
38
+
39
+ ### Dummy app
40
+
41
+ Run the dummy app from the root of the project with `bin/dev` in one tab and `bin/rails s` in another (so debugger will work).
42
+
43
+ ### Tests
44
+
45
+ Run the tests from the root of the project with `rspec`.
46
+
47
+ ### VCR
48
+
49
+ AI::Engine uses VCR to record HTTP requests and responses. By default, specs are run against recorded 'cassette' fixtures.
50
+
51
+ Set OPENAI_ACCESS_TOKEN= in your .env file to run the specs against a live API and re-record all cassettes - this will cost you money!
52
+
53
+ ### Release
54
+
55
+ First run the specs without VCR so they actually hit the API. This will cost 2 cents or more. Set OPENAI_ACCESS_TOKEN in your environment or pass it in like this:
56
+
57
+ ```
58
+ OPENAI_ACCESS_TOKEN=123abc bundle exec rspec
59
+ ```
12
60
 
13
- You can find the real AI::Engine and documentation at [insertrobot.com](https://insertrobot.com)!
61
+ Then update the version number in `version.rb`, update `CHANGELOG.md`, run `bundle install` to update Gemfile.lock, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
data/Rakefile CHANGED
@@ -1,2 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
1
8
  require "bundler/gem_tasks"
2
- task :default => :spec
@@ -0,0 +1,7 @@
1
+ module AI
2
+ module Engine
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ module AI::Engine
2
+ class Assistant < ApplicationRecord
3
+ include RemoteIdValidatable
4
+
5
+ MIN_PROMPT_TOKENS = 256
6
+ MIN_COMPLETION_TOKENS = 16
7
+
8
+ belongs_to :assistable, polymorphic: true
9
+ has_many :runs, class_name: "AI::Engine::Run", foreign_key: "ai_engine_assistant_id", dependent: :nullify
10
+
11
+ def to_partial_path
12
+ "assistants/assistant"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ module AI::Engine
2
+ class AssistantThread < ApplicationRecord
3
+ include RemoteIdValidatable
4
+
5
+ belongs_to :threadable, polymorphic: true
6
+ has_many :runs, class_name: "AI::Engine::Run", foreign_key: "ai_engine_assistant_thread_id", dependent: :nullify
7
+ has_many :messages, as: :messageable, class_name: "AI::Engine::Message", foreign_key: "messageable_id", dependent: :nullify
8
+
9
+ before_create :create_openai_thread
10
+
11
+ def run(assistant_id:, content:)
12
+ # Create the request Message, locally and remotely on OpenAI.
13
+ AI::Engine::Message.create(messageable: self, content: content, role: "user")
14
+
15
+ # Run the Thread using the selected Assistant.
16
+ runs.create(ai_engine_assistant_id: assistant_id)
17
+ end
18
+
19
+ def to_partial_path
20
+ "assistant_threads/assistant_thread"
21
+ end
22
+
23
+ private
24
+
25
+ def create_openai_thread
26
+ self.remote_id = AI::Engine::OpenAI::Threads::Create.call
27
+ rescue Faraday::Error => e
28
+ errors.add(:base, e.message)
29
+ throw(:abort)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ module AI::Engine
2
+ class Chat < ApplicationRecord
3
+ belongs_to :chattable, polymorphic: true
4
+ has_many :messages, as: :messageable, class_name: "AI::Engine::Message", foreign_key: "messageable_id", dependent: :nullify
5
+
6
+ def run(model:)
7
+ # Run the Chat, sending the complete message history to OpenAI.
8
+ AI::Engine::OpenAI::Chats::Stream.call(chat_id: id, stream: stream(model: model), model: model)
9
+ end
10
+
11
+ def messages_for_openai
12
+ messages.order(:created_at).map do |message|
13
+ {
14
+ role: message.role,
15
+ content: message.content
16
+ }
17
+ end.filter { |message| message[:content].present? }
18
+ end
19
+
20
+ def stream(model:)
21
+ response_message = messages.create(
22
+ role: "assistant",
23
+ content: "",
24
+ model: model
25
+ )
26
+
27
+ proc do |chunk, _bytesize|
28
+ if chunk["object"] == "chat.completion.chunk"
29
+ new_content = chunk.dig("choices", 0, "delta", "content")
30
+ response_message.update(content: response_message.content + new_content) if new_content
31
+ end
32
+ if chunk["usage"]
33
+ response_message.update(
34
+ prompt_token_usage: chunk.dig("usage", "prompt_tokens"),
35
+ completion_token_usage: chunk.dig("usage", "completion_tokens")
36
+ )
37
+ end
38
+ end
39
+ end
40
+
41
+ def to_partial_path
42
+ "chats/chat"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,73 @@
1
+ module AI::Engine
2
+ class Message < ApplicationRecord
3
+ include RemoteIdValidatable
4
+
5
+ belongs_to :messageable, polymorphic: true # AI::Engine::Chat or AI::Engine::AssistantThread
6
+ belongs_to :run, class_name: "AI::Engine::Run", foreign_key: "ai_engine_run_id", optional: true
7
+
8
+ enum role: {system: 0, assistant: 10, user: 20}
9
+
10
+ before_create :create_openai_message,
11
+ if: -> { in_assistant_thread? }, # Chat messages are only stored locally.
12
+ unless: -> { assistant? } # Checking the role - assistant messages on the OpenAI side are created by a Run.
13
+ after_create :on_create
14
+ after_update :on_update
15
+
16
+ def input_cost
17
+ return unless assistant?
18
+
19
+ return unless prompt_token_usage.present? && model.present?
20
+
21
+ (prompt_token_usage.to_i * AI::Engine::DOLLAR_COST_PER_1K_TOKENS[model]["input"] / 1000).round(4)
22
+ end
23
+
24
+ def output_cost
25
+ return unless assistant?
26
+
27
+ return unless completion_token_usage.present? && model.present?
28
+
29
+ (completion_token_usage.to_i * AI::Engine::DOLLAR_COST_PER_1K_TOKENS[model]["output"] / 1000).round(4)
30
+ end
31
+
32
+ def user
33
+ in_chat? ? messageable.chattable : messageable.threadable
34
+ end
35
+
36
+ def in_chat?
37
+ messageable.is_a?(AI::Engine::Chat)
38
+ end
39
+
40
+ def in_assistant_thread?
41
+ messageable.is_a?(AI::Engine::AssistantThread)
42
+ end
43
+
44
+ def on_create
45
+ if in_chat?
46
+ messageable.chattable.ai_engine_on_message_create(message: self)
47
+ else
48
+ messageable.threadable.ai_engine_on_message_create(message: self)
49
+ end
50
+ end
51
+
52
+ def on_update
53
+ if in_chat?
54
+ messageable.chattable.ai_engine_on_message_update(message: self)
55
+ else
56
+ messageable.threadable.ai_engine_on_message_update(message: self)
57
+ end
58
+ end
59
+
60
+ def to_partial_path
61
+ "messages/message"
62
+ end
63
+
64
+ private
65
+
66
+ def create_openai_message
67
+ self.remote_id = AI::Engine::OpenAI::Messages::Create.call(thread_id: messageable.remote_id, content: content, role: role)
68
+ rescue Faraday::Error => e
69
+ errors.add(:base, e.message)
70
+ throw(:abort)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,58 @@
1
+ module AI::Engine
2
+ class Run < ApplicationRecord
3
+ include AI::Engine::RemoteIdValidatable
4
+
5
+ belongs_to :assistant, class_name: "AI::Engine::Assistant", foreign_key: "ai_engine_assistant_id"
6
+ belongs_to :assistant_thread, class_name: "AI::Engine::AssistantThread", foreign_key: "ai_engine_assistant_thread_id"
7
+ has_many :messages, class_name: "AI::Engine::Message", foreign_key: "ai_engine_run_id", dependent: :nullify
8
+
9
+ after_create :create_openai_run
10
+
11
+ def to_partial_path
12
+ "runs/run"
13
+ end
14
+
15
+ private
16
+
17
+ def create_openai_run
18
+ AI::Engine::OpenAI::Runs::Create.call(
19
+ assistant_id: assistant.remote_id,
20
+ thread_id: assistant_thread.remote_id,
21
+ stream: stream
22
+ )
23
+ rescue Faraday::Error => e
24
+ errors.add(:base, e.message)
25
+ throw(:abort)
26
+ end
27
+
28
+ def stream
29
+ response_message = assistant_thread.messages.create(
30
+ ai_engine_run_id: id,
31
+ model: assistant.assistable.model,
32
+ role: "assistant",
33
+ content: ""
34
+ )
35
+ proc do |chunk, _bytesize|
36
+ if chunk["object"] == "thread.message.delta"
37
+ new_content = chunk.dig("delta", "content", 0, "text", "value")
38
+ response_message.update(content: response_message.content + new_content) if new_content
39
+ elsif chunk["status"] == "completed"
40
+ if chunk["run_id"].present? && !remote_id.present?
41
+ update(remote_id: chunk["run_id"])
42
+ remote_run = AI::Engine::OpenAI::Runs::Retrieve.call(remote_id: chunk["run_id"], thread_id: assistant_thread.remote_id)
43
+ if remote_run.present?
44
+ response_message.update(
45
+ prompt_token_usage: remote_run.dig("usage", "prompt_tokens"),
46
+ completion_token_usage: remote_run.dig("usage", "completion_tokens")
47
+ )
48
+ end
49
+ end
50
+
51
+ if chunk["id"].present? && chunk["id"].start_with?(AI::Engine::Message.remote_id_prefix) && !response_message.remote_id.present?
52
+ response_message.update(remote_id: chunk["id"])
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,48 @@
1
+ module AI
2
+ module Engine
3
+ module Assistable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_one :assistant, as: :assistable, class_name: "AI::Engine::Assistant"
8
+
9
+ before_create :create_openai_assistant
10
+ before_update :update_openai_assistant
11
+
12
+ # Default. Override in including model to customize.
13
+ def ai_engine_assistant
14
+ Logger.new($stdout).info("ai_engine_assistant called - add `def ai_engine_assistant` to your #{self.class.name} model to define the Assistant params. The method should return a Hash of: {name:, model:, description:, instructions:}.")
15
+ {
16
+ name: "Assistant for #{self.class.name} #{id}",
17
+ model: AI::Engine::MODEL_OPTIONS.first,
18
+ description: "Assistant for #{self.class.name} #{id}",
19
+ instructions: "Assistant for #{self.class.name} #{id}"
20
+ }
21
+ end
22
+
23
+ def ai_engine_run(assistant_thread:, content:)
24
+ assistant_thread.run(assistant_id: assistant.id, content: content)
25
+ end
26
+
27
+ private
28
+
29
+ def create_openai_assistant
30
+ build_assistant
31
+ begin
32
+ assistant.remote_id = AI::Engine::OpenAI::Assistants::Create.call(**ai_engine_assistant)
33
+ rescue Faraday::Error => e
34
+ errors.add(:base, e.message)
35
+ throw(:abort)
36
+ end
37
+ end
38
+
39
+ def update_openai_assistant
40
+ AI::Engine::OpenAI::Assistants::Update.call(remote_id: assistant&.remote_id, **ai_engine_assistant)
41
+ rescue Faraday::Error => e
42
+ errors.add(:base, e.message)
43
+ throw(:abort)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ module AI
2
+ module Engine
3
+ module Chattable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :chats, as: :chattable, class_name: "AI::Engine::Chat"
8
+
9
+ def ai_engine_on_message_create(message:)
10
+ # This is a hook for the AI Engine to notify the Chattable that a Message has been updated.
11
+ # Override this method in your Chattable model to handle the event.
12
+ Logger.new($stdout).info("ai_engine_on_message_create called - add `def ai_engine_on_message_create(message:)` to your Chattable model to handle this event.")
13
+ end
14
+
15
+ def ai_engine_on_message_update(message:)
16
+ # This is a hook for the AI Engine to notify the Chattable that a Message has been updated.
17
+ # Override this method in your Chattable model to handle the event.
18
+ Logger.new($stdout).info("ai_engine_on_message_update called - add `def ai_engine_on_message_update(message:)` to your Chattable model to handle this event.")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ module AI
2
+ module Engine
3
+ module RemoteIdValidatable
4
+ extend ActiveSupport::Concern
5
+
6
+ REMOTE_ID_PREFIXES = {
7
+ run: "run_",
8
+ message: "msg_",
9
+ assistant: "asst_",
10
+ assistant_thread: "thread_"
11
+ }.freeze
12
+
13
+ included do
14
+ # Validate the remote_id format is correct.
15
+ validate :remote_id_format
16
+
17
+ def remote_id_format
18
+ prefix = self.class.remote_id_prefix
19
+ return if remote_id.nil? || remote_id.start_with?(prefix)
20
+
21
+ errors.add(:remote_id, "ID '#{remote_id}' must start with '#{prefix}'")
22
+ end
23
+ end
24
+
25
+ class_methods do
26
+ def remote_id_prefix
27
+ REMOTE_ID_PREFIXES[name.demodulize.underscore.to_sym]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ module AI
2
+ module Engine
3
+ module Threadable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :assistant_threads, as: :threadable, class_name: "AI::Engine::AssistantThread"
8
+
9
+ def ai_engine_on_message_create(message:)
10
+ # This is a hook for the AI Engine to notify the AssistantThreadtable that a Message has been updated.
11
+ # Override this method in your AssistantThreadtable model to handle the event.
12
+ Logger.new($stdout).info("ai_engine_on_message_create called - add `def ai_engine_on_message_create(message:)` to your Threadable model to handle this event.")
13
+ end
14
+
15
+ def ai_engine_on_message_update(message:)
16
+ # This is a hook for the AI Engine to notify the AssistantThreadtable that a Message has been updated.
17
+ # Override this method in your AssistantThreadtable model to handle the event.
18
+ Logger.new($stdout).info("ai_engine_on_message_update called - add `def ai_engine_on_message_update(message:)` to your Threadable model to handle this event.")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ class AI::Engine::OpenAI::Assistants::Create
2
+ # Creates a new Assistant on the OpenAI API.
3
+ # Returns the OpenAI ID of the new Assistant.
4
+ def self.call(name:, model:, description:, instructions:)
5
+ response = client.assistants.create(
6
+ parameters: {
7
+ name: name,
8
+ model: model,
9
+ description: description,
10
+ instructions: instructions
11
+ }
12
+ )
13
+
14
+ response["id"]
15
+ end
16
+
17
+ private_class_method def self.client
18
+ @client ||= OpenAI::Client.new(
19
+ access_token: AI::Engine::Engine.config.openai_access_token,
20
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
21
+ log_errors: Rails.env.development? || Rails.env.test?,
22
+ request_timeout: 2.minutes.to_i
23
+ )
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ class AI::Engine::OpenAI::Assistants::Retrieve
2
+ # Retrieves an OpenAI Assistant by its ID.
3
+ def self.call(remote_id:)
4
+ client.assistants.retrieve(id: remote_id)
5
+ end
6
+
7
+ private_class_method def self.client
8
+ @client ||= OpenAI::Client.new(
9
+ access_token: AI::Engine::Engine.config.openai_access_token,
10
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
11
+ log_errors: Rails.env.development? || Rails.env.test?,
12
+ request_timeout: 2.minutes.to_i
13
+ )
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ class AI::Engine::OpenAI::Assistants::Update
2
+ # Updates an OpenAI Assistant with the given parameters.
3
+ def self.call(remote_id:, name: nil, model: nil, description: nil, instructions: nil)
4
+ parameters = {
5
+ name: name,
6
+ model: model,
7
+ description: description,
8
+ instructions: instructions
9
+ }.compact
10
+
11
+ client.assistants.modify(
12
+ id: remote_id,
13
+ parameters: parameters
14
+ )
15
+ end
16
+
17
+ private_class_method def self.client
18
+ @client ||= OpenAI::Client.new(
19
+ access_token: AI::Engine::Engine.config.openai_access_token,
20
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
21
+ log_errors: Rails.env.development? || Rails.env.test?,
22
+ request_timeout: 2.minutes.to_i
23
+ )
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ class AI::Engine::OpenAI::Chats::Stream
2
+ # Gets the next message response to a set of messages.
3
+ def self.call(chat_id:, stream:, model:)
4
+ chat = AI::Engine::Chat.find(chat_id)
5
+
6
+ client.chat(
7
+ parameters: {
8
+ model: model,
9
+ messages: chat.messages_for_openai,
10
+ stream: stream,
11
+ stream_options: {include_usage: true}
12
+ }
13
+ )
14
+ end
15
+
16
+ private_class_method def self.client
17
+ @client ||= OpenAI::Client.new(
18
+ access_token: AI::Engine::Engine.config.openai_access_token,
19
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
20
+ log_errors: Rails.env.development? || Rails.env.test?,
21
+ request_timeout: 2.minutes.to_i
22
+ )
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ class AI::Engine::OpenAI::Messages::Create
2
+ # Gets the OpenAI ID of a new Message.
3
+ def self.call(thread_id:, content:, role:)
4
+ response = client.messages.create(
5
+ thread_id: thread_id,
6
+ parameters: {
7
+ content: content,
8
+ role: role
9
+ }
10
+ )
11
+
12
+ response["id"]
13
+ end
14
+
15
+ private_class_method def self.client
16
+ @client ||= OpenAI::Client.new(
17
+ access_token: AI::Engine::Engine.config.openai_access_token,
18
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
19
+ log_errors: Rails.env.development? || Rails.env.test?,
20
+ request_timeout: 2.minutes.to_i
21
+ )
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ class AI::Engine::OpenAI::Runs::Create
2
+ # Creates a new Run on the OpenAI API.
3
+ def self.call(assistant_id:, thread_id:, stream: false)
4
+ client.runs.create(
5
+ thread_id: thread_id,
6
+ parameters: {
7
+ assistant_id: assistant_id,
8
+ stream: stream
9
+ }
10
+ )
11
+ end
12
+
13
+ private_class_method def self.client
14
+ @client ||= OpenAI::Client.new(
15
+ access_token: AI::Engine::Engine.config.openai_access_token,
16
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
17
+ log_errors: Rails.env.development? || Rails.env.test?,
18
+ request_timeout: 2.minutes.to_i
19
+ )
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ class AI::Engine::OpenAI::Runs::Retrieve
2
+ # Retrieves an OpenAI Run by its ID.
3
+ def self.call(remote_id:, thread_id:)
4
+ client.runs.retrieve(id: remote_id, thread_id: thread_id)
5
+ end
6
+
7
+ private_class_method def self.client
8
+ @client ||= OpenAI::Client.new(
9
+ access_token: AI::Engine::Engine.config.openai_access_token,
10
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
11
+ log_errors: Rails.env.development? || Rails.env.test?,
12
+ request_timeout: 2.minutes.to_i
13
+ )
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ class AI::Engine::OpenAI::Threads::Create
2
+ # Gets the OpenAI ID of a new Thread.
3
+ def self.call
4
+ response = client.threads.create(parameters: {})
5
+
6
+ response["id"]
7
+ end
8
+
9
+ private_class_method def self.client
10
+ @client ||= OpenAI::Client.new(
11
+ access_token: AI::Engine::Engine.config.openai_access_token,
12
+ organization_id: AI::Engine::Engine.config.openai_organization_id,
13
+ log_errors: Rails.env.development? || Rails.env.test?,
14
+ request_timeout: 2.minutes.to_i
15
+ )
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
2
+ inflect.acronym "AI"
3
+ inflect.acronym "OpenAI"
4
+ end
@@ -0,0 +1 @@
1
+ require "openai"
@@ -0,0 +1,12 @@
1
+ class CreateAIEngineAssistants < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ai_engine_assistants do |t|
4
+ t.string :remote_id
5
+ t.belongs_to :assistable, polymorphic: true
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :ai_engine_assistants, %i[assistable_type assistable_id remote_id], unique: true
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ class CreateAIEngineAssistantThreads < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ai_engine_assistant_threads do |t|
4
+ t.string :remote_id
5
+ t.belongs_to :threadable, polymorphic: true
6
+
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ class CreateAIEngineRuns < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ai_engine_runs do |t|
4
+ t.string :remote_id
5
+ t.references :ai_engine_assistant, foreign_key: true
6
+ t.references :ai_engine_assistant_thread, foreign_key: true
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ class CreateAIEngineMessages < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ai_engine_messages do |t|
4
+ t.string :remote_id
5
+ t.references :ai_engine_run, foreign_key: true
6
+ t.references :messageable, polymorphic: true
7
+ t.integer :role, null: false, default: 0
8
+ t.string :content, null: false
9
+ t.string :model
10
+ t.integer :prompt_token_usage
11
+ t.integer :completion_token_usage
12
+
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ class CreateAIEngineChats < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ai_engine_chats do |t|
4
+ t.belongs_to :chattable, polymorphic: true
5
+
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,58 @@
1
+ module AI
2
+ module Engine
3
+ DOLLAR_COST_PER_1K_TOKENS = {
4
+ "gpt-3.5-turbo" => {
5
+ "input" => 0.0005,
6
+ "output" => 0.0015
7
+ },
8
+ "gpt-4" => {
9
+ "input" => 0.03,
10
+ "output" => 0.06
11
+ },
12
+ "gpt-4-turbo" => {
13
+ "input" => 0.01,
14
+ "output" => 0.03
15
+ },
16
+ "gpt-4o" => {
17
+ "input" => 0.005,
18
+ "output" => 0.015
19
+ }
20
+ }.freeze
21
+ MODEL_OPTIONS = DOLLAR_COST_PER_1K_TOKENS.keys.freeze
22
+
23
+ def self.setup(&)
24
+ Engine.setup(&)
25
+ end
26
+
27
+ class Engine < ::Rails::Engine
28
+ isolate_namespace AI::Engine
29
+
30
+ class Configuration
31
+ attr_accessor :openai_access_token, :openai_organization_id
32
+
33
+ def initialize
34
+ @openai_access_token = nil
35
+ @openai_organization_id = nil
36
+ end
37
+ end
38
+
39
+ config.generators do |g|
40
+ g.test_framework :rspec
41
+ g.fixture_replacement :factory_bot
42
+ g.factory_bot dir: "spec/factories"
43
+ end
44
+
45
+ def self.setup(&block)
46
+ @config ||= AI::Engine::Engine::Configuration.new
47
+
48
+ yield @config if block
49
+
50
+ @config
51
+ end
52
+
53
+ def self.config
54
+ @config || setup
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,5 @@
1
- module Ai
1
+ module AI
2
2
  module Engine
3
- VERSION = "0.0.1"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
data/lib/ai/engine.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "ai/engine/version"
2
+ require "ai/engine/engine"
2
3
 
3
- module Ai
4
+ module AI
4
5
  module Engine
5
6
  # Your code goes here...
6
7
  end
metadata CHANGED
@@ -1,67 +1,74 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Rudall
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-10 00:00:00.000000000 Z
11
+ date: 2024-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: bundler
14
+ name: ruby-openai
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.5'
20
- type: :development
19
+ version: 7.1.0
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: '2.5'
27
- - !ruby/object:Gem::Dependency
28
- name: rake
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '13.2'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '13.2'
41
- description: You're probably looking for the real ai-engine gem at https://insertrobot.com
26
+ version: 7.1.0
27
+ description: A Rails Engine from the creator of ruby-openai.
42
28
  email:
43
- - alex@insertrobot.com
29
+ - hello@alexrudall.com
44
30
  executables: []
45
31
  extensions: []
46
32
  extra_rdoc_files: []
47
33
  files:
48
- - ".gitignore"
49
- - Gemfile
50
- - LICENSE.txt
51
34
  - README.md
52
35
  - Rakefile
53
- - ai-engine.gemspec
54
- - bin/console
55
- - bin/setup
36
+ - app/models/ai/engine/application_record.rb
37
+ - app/models/ai/engine/assistant.rb
38
+ - app/models/ai/engine/assistant_thread.rb
39
+ - app/models/ai/engine/chat.rb
40
+ - app/models/ai/engine/message.rb
41
+ - app/models/ai/engine/run.rb
42
+ - app/models/concerns/ai/engine/assistable.rb
43
+ - app/models/concerns/ai/engine/chattable.rb
44
+ - app/models/concerns/ai/engine/remote_id_validatable.rb
45
+ - app/models/concerns/ai/engine/threadable.rb
46
+ - app/services/ai/engine/openai/assistants/create.rb
47
+ - app/services/ai/engine/openai/assistants/retrieve.rb
48
+ - app/services/ai/engine/openai/assistants/update.rb
49
+ - app/services/ai/engine/openai/chats/stream.rb
50
+ - app/services/ai/engine/openai/messages/create.rb
51
+ - app/services/ai/engine/openai/runs/create.rb
52
+ - app/services/ai/engine/openai/runs/retrieve.rb
53
+ - app/services/ai/engine/openai/threads/create.rb
54
+ - config/initializers/inflections.rb
55
+ - config/initializers/openai.rb
56
+ - db/migrate/20240528153439_create_ai_engine_assistants.rb
57
+ - db/migrate/20240530121855_create_ai_engine_assistant_threads.rb
58
+ - db/migrate/20240530130834_create_ai_engine_runs.rb
59
+ - db/migrate/20240530135629_create_ai_engine_messages.rb
60
+ - db/migrate/20240619150010_create_ai_engine_chats.rb
56
61
  - lib/ai/engine.rb
62
+ - lib/ai/engine/engine.rb
57
63
  - lib/ai/engine/version.rb
58
- homepage: https://github.com/alexrudall/ai-engine-placeholder
64
+ homepage: https://railsai.com/docs/installation
59
65
  licenses:
60
66
  - MIT
61
67
  metadata:
62
- allowed_push_host: https://rubygems.org
63
- post_install_message: "/!\\ ಠ‿ಠ Watch out ! This is not the real ai-engine gem - get
64
- the real one from insertrobot.com! /!\\"
68
+ homepage_uri: https://railsai.com/docs/installation
69
+ source_code_uri: https://github.com/alexrudall/ai-engine/
70
+ changelog_uri: https://github.com/alexrudall/ai-engine/blob/main/CHANGELOG.md
71
+ post_install_message:
65
72
  rdoc_options: []
66
73
  require_paths:
67
74
  - lib
@@ -69,7 +76,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
76
  requirements:
70
77
  - - ">="
71
78
  - !ruby/object:Gem::Version
72
- version: '0'
79
+ version: '3.0'
73
80
  required_rubygems_version: !ruby/object:Gem::Requirement
74
81
  requirements:
75
82
  - - ">="
@@ -79,5 +86,5 @@ requirements: []
79
86
  rubygems_version: 3.5.11
80
87
  signing_key:
81
88
  specification_version: 4
82
- summary: Placeholder for the official ai-engine gem
89
+ summary: The easiest way to get AI into your Rails app.
83
90
  test_files: []
data/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
data/Gemfile DELETED
@@ -1,4 +0,0 @@
1
- source 'https://rubygems.org'
2
-
3
- # Specify your gem's dependencies in ai-engine.gemspec
4
- gemspec
data/LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2024 Alex Rudall
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
data/ai-engine.gemspec DELETED
@@ -1,37 +0,0 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'ai/engine/version'
5
-
6
- Gem::Specification.new do |spec|
7
- spec.name = "ai-engine"
8
- spec.version = Ai::Engine::VERSION
9
- spec.authors = ["Alex Rudall"]
10
- spec.email = ["alex@insertrobot.com"]
11
-
12
- spec.summary = %q{Placeholder for the official ai-engine gem}
13
- spec.description = %q{You're probably looking for the real ai-engine gem at https://insertrobot.com}
14
- spec.homepage = "https://github.com/alexrudall/ai-engine-placeholder"
15
- spec.license = "MIT"
16
-
17
- # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
- # to allow pushing to a single host or delete this section to allow pushing to any host.
19
- if spec.respond_to?(:metadata)
20
- spec.metadata['allowed_push_host'] = "https://rubygems.org"
21
- else
22
- raise "RubyGems 2.0 or newer is required to protect against " \
23
- "public gem pushes."
24
- end
25
-
26
- spec.post_install_message = "/!\\ ಠ‿ಠ Watch out ! This is not the real ai-engine gem - get the real one from insertrobot.com! /!\\"
27
-
28
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
29
- f.match(%r{^(test|spec|features)/})
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- spec.add_development_dependency "bundler", "~> 2.5"
36
- spec.add_development_dependency "rake", "~> 13.2"
37
- end
data/bin/console DELETED
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "bundler/setup"
4
- require "ai/engine"
5
-
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start(__FILE__)
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here