Add spend limits to conversations
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
|
||||
.conversation__message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.action-text-content {
|
||||
border-radius: 0.5rem;
|
||||
@@ -51,7 +52,7 @@
|
||||
}
|
||||
|
||||
.conversation__message--assistant {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
.action-text-content {
|
||||
margin-inline-end: 15%;
|
||||
@@ -67,8 +68,15 @@
|
||||
color: var(--color-negative);
|
||||
}
|
||||
|
||||
.conversation__message--failed[data-error]::after {
|
||||
color: var(--color-negative);
|
||||
content: attr(data-error);
|
||||
display: block;
|
||||
padding: var(--inline-space);
|
||||
}
|
||||
|
||||
.conversation__message--user {
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
|
||||
.action-text-content {
|
||||
background-color: var(--color-selected);
|
||||
|
||||
@@ -7,6 +7,10 @@ class Conversations::MessagesController < ApplicationController
|
||||
|
||||
def create
|
||||
@conversation.ask(question, **message_params)
|
||||
rescue Conversation::CostExceededError
|
||||
render json: { error: "You've asked too many questions this week" }, status: :too_many_requests
|
||||
rescue Conversation::InvalidStateError
|
||||
render json: { error: "Fizzy is still working on an answer to your last question" }, status: :conflict
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -27,7 +27,10 @@ export default class extends Controller {
|
||||
if (event.detail.success) {
|
||||
this.#reset()
|
||||
} else {
|
||||
this.conversationMessagesOutlet.failPendingMessage(this.clientMessageIdInputTarget.value)
|
||||
this.#failPendingMessage(
|
||||
this.clientMessageIdInputTarget.value,
|
||||
event.detail.fetchResponse
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,4 +63,18 @@ export default class extends Controller {
|
||||
this.textInputTarget.value = ""
|
||||
this.clientMessageIdInputTarget.value = ""
|
||||
}
|
||||
|
||||
async #failPendingMessage(clientMessageId, response) {
|
||||
let errorMessage = null
|
||||
|
||||
if (response?.contentType?.includes('application/json')) {
|
||||
const error = JSON.parse(await response.responseText)
|
||||
errorMessage = error.error
|
||||
}
|
||||
|
||||
this.conversationMessagesOutlet.failPendingMessage(
|
||||
this.clientMessageIdInputTarget.value,
|
||||
errorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ export default class extends Controller {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
|
||||
failPendingMessage(clientMessageId) {
|
||||
failPendingMessage(clientMessageId, errorMessage) {
|
||||
const message = this.element.querySelector(`#message_${clientMessageId}`)
|
||||
|
||||
if (message) {
|
||||
message.classList.add(this.failedMessageClass)
|
||||
|
||||
if (errorMessage) {
|
||||
message.dataset.error = errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
class Conversation < ApplicationRecord
|
||||
class InvalidStateError < StandardError; end
|
||||
class CostExceededError < StandardError; end
|
||||
|
||||
include Broadcastable
|
||||
include Broadcastable, CostLimited
|
||||
|
||||
belongs_to :user, class_name: "User"
|
||||
has_many :messages, dependent: :destroy
|
||||
|
||||
enum :state, %w[ ready thinking ].index_by(&:itself), default: :ready
|
||||
|
||||
def cost
|
||||
messages.where.not(cost_microcents: nil).sum(:cost_microcents).to_d / 100_000
|
||||
end
|
||||
|
||||
def clear
|
||||
messages.delete_all
|
||||
touch
|
||||
end
|
||||
|
||||
def ask(question, **attributes)
|
||||
limit_cost to: "$100", within: 7.days
|
||||
|
||||
create_message_with_state_change(**attributes, role: :user, content: question) do
|
||||
raise(InvalidStateError, "Can't ask questions while thinking") if thinking?
|
||||
thinking!
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
module Conversation::Cost
|
||||
CENTS_PER_DOLLAR = 100
|
||||
MICROCENTS_PER_CENT = 1_000_000
|
||||
MICROCENTS_PER_DOLLAR = CENTS_PER_DOLLAR * MICROCENTS_PER_CENT
|
||||
NUMBER_REGEX = /\d+(\.\d+)?/
|
||||
|
||||
extend self
|
||||
|
||||
def convert_to_microcents(value)
|
||||
case value
|
||||
when String
|
||||
decimal_to_microcents(value[NUMBER_REGEX].to_d)
|
||||
when Float, BigDecimal
|
||||
decimal_to_microcents(value)
|
||||
when Numeric
|
||||
value
|
||||
else
|
||||
raise ArgumentError, "Invalid cost value: #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
def convert_to_decimal(microcents)
|
||||
microcents.to_d / MICROCENTS_PER_DOLLAR
|
||||
end
|
||||
|
||||
private
|
||||
def decimal_to_microcents(decimal)
|
||||
(decimal.to_d * MICROCENTS_PER_DOLLAR).round.to_i
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
module Conversation::CostLimited
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def limit_cost(to:, within: nil)
|
||||
raise Conversation::CostExceededError, "Cost limit exceeded" if cost_exceeds?(to, within: within)
|
||||
end
|
||||
|
||||
def cost_exceeds?(amount, within: nil)
|
||||
cost_microcents(within: within) >= Conversation::Cost.convert_to_microcents(amount)
|
||||
end
|
||||
|
||||
def cost(...)
|
||||
Conversation::Cost.convert_to_decimal cost_microcents(...)
|
||||
end
|
||||
|
||||
def cost_microcents(within: nil)
|
||||
within = (within.ago...) if within && !within.is_a?(Range)
|
||||
|
||||
scope = if within
|
||||
messages.where(created_at: within)
|
||||
else
|
||||
messages
|
||||
end
|
||||
|
||||
scope.with_cost.sum(:cost_microcents)
|
||||
end
|
||||
end
|
||||
@@ -12,6 +12,7 @@ class Conversation::Message < ApplicationRecord
|
||||
validates :client_message_id, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :asc, id: :asc) }
|
||||
scope :with_cost, -> { where.not(cost_microcents: nil) }
|
||||
|
||||
def all_emoji?
|
||||
content.to_plain_text.all_emoji?
|
||||
|
||||
@@ -11,7 +11,7 @@ class Conversation::Message::ResponseGenerator::Response
|
||||
end
|
||||
|
||||
def cost
|
||||
cost_microcents.to_d / MICROCENTS_PER_DOLLAR
|
||||
Conversation::Cost.convert_to_decimal(cost_microcents)
|
||||
end
|
||||
|
||||
def cost_microcents
|
||||
@@ -49,6 +49,6 @@ class Conversation::Message::ResponseGenerator::Response
|
||||
private
|
||||
def price_per_million_tokens_to_microcents(price)
|
||||
single_token_price = price.to_d / 1_000_000
|
||||
single_token_price * MICROCENTS_PER_DOLLAR
|
||||
Conversation::Cost.convert_to_microcents(single_token_price)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,21 +13,21 @@ class Conversation::Message::ResponseGenerator::ResponseTest < ActiveSupport::Te
|
||||
# and 60 USD per million output tokens
|
||||
# That's 0.00003 cents per input token and 0.00006 cents
|
||||
# per output token
|
||||
# Which is 3 micro-cents per input token and 6 micro-cents
|
||||
# Which is 3000 micro-cents per input token and 6000 micro-cents
|
||||
# per output token
|
||||
assert_equal "3.0".to_d, response.input_token_price_microcents
|
||||
assert_equal "6.0".to_d, response.output_token_price_microcents
|
||||
assert_equal "3000.0".to_d, response.input_token_price_microcents
|
||||
assert_equal "6000.0".to_d, response.output_token_price_microcents
|
||||
|
||||
# We've got 198 input tokens, so that's
|
||||
# 193 * 3 = 594
|
||||
assert_equal 594, response.input_cost_microcents
|
||||
# 198 * 3000 = 594000
|
||||
assert_equal 594000, response.input_cost_microcents
|
||||
|
||||
# We've got 2 output tokens, so that's
|
||||
# 2 * 6 = 12
|
||||
assert_equal 12, response.output_cost_microcents
|
||||
# 2 * 6000 = 12
|
||||
assert_equal 12000, response.output_cost_microcents
|
||||
|
||||
# So the total is 594 + 12 micro-cents
|
||||
assert_equal 606, response.cost_microcents
|
||||
# So the total is 594000 + 12000 micro-cents
|
||||
assert_equal 606000, response.cost_microcents
|
||||
|
||||
# If we convert that to a decimal value we get 0.00606 cents
|
||||
assert_equal "0.00606".to_d, response.cost
|
||||
|
||||
@@ -61,21 +61,31 @@ class ConversationTest < ActiveSupport::TestCase
|
||||
assert conversation.ready?, "The conversation should switch back to ready after a response is made"
|
||||
end
|
||||
|
||||
test "clearing conversation messages" do
|
||||
conversation = conversations(:kevin)
|
||||
|
||||
assert conversation.messages.any?, "The conversation should have messages before clearing"
|
||||
|
||||
original_updated_at = conversation.updated_at
|
||||
conversation.clear
|
||||
assert conversation.updated_at > original_updated_at, "The conversation's updated_at timestamp should change after clearing messages"
|
||||
|
||||
assert conversation.messages.empty?, "All messages should be deleted when clearing the conversation"
|
||||
end
|
||||
|
||||
test "cost calculation" do
|
||||
conversation = conversations(:kevin)
|
||||
|
||||
assert_equal "0.01053".to_d, conversation.cost
|
||||
assert_equal "0.00001053".to_d, conversation.cost
|
||||
end
|
||||
|
||||
test "cost limits" do
|
||||
conversation = conversations(:kevin)
|
||||
|
||||
conversation.ask("Where does the planning office keep demolition notices?")
|
||||
conversation.respond(
|
||||
"In a locked filing cabinet in a disused lavatory",
|
||||
cost_microcents: Conversation::Cost.convert_to_microcents("$3")
|
||||
)
|
||||
|
||||
conversation.ask("What's the meaning of life?")
|
||||
conversation.respond("42", cost_microcents: Conversation::Cost.convert_to_microcents("$120"))
|
||||
|
||||
assert_raises Conversation::CostExceededError do
|
||||
conversation.ask("Should you leave a house without a towel?")
|
||||
end
|
||||
|
||||
travel 1.month
|
||||
|
||||
conversation.ask("Should you leave a house without a towel?")
|
||||
conversation.respond("Never", cost_microcents: Conversation::Cost.convert_to_microcents("$0.01"))
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user