Add spend limits to conversations

This commit is contained in:
Stanko K.R.
2025-08-18 08:47:35 +02:00
parent 78fdabf62f
commit cb5fe7c4f8
11 changed files with 133 additions and 38 deletions
+10 -2
View File
@@ -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
}
}
}
+4 -10
View File
@@ -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!
+30
View File
@@ -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
+27
View File
@@ -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
+1
View File
@@ -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
+23 -13
View File
@@ -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