From cb5fe7c4f8123edd847396f443f9bead01808a8d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 18 Aug 2025 08:47:35 +0200 Subject: [PATCH] Add spend limits to conversations --- app/assets/stylesheets/conversation.css | 12 +++++-- .../conversations/messages_controller.rb | 4 +++ .../conversation/composer_controller.js | 19 +++++++++- .../conversation/messages_controller.js | 6 +++- app/models/conversation.rb | 14 +++----- app/models/conversation/cost.rb | 30 ++++++++++++++++ app/models/conversation/cost_limited.rb | 27 ++++++++++++++ app/models/conversation/message.rb | 1 + .../message/response_generator/response.rb | 4 +-- .../response_generator/response_test.rb | 18 +++++----- test/models/conversation_test.rb | 36 ++++++++++++------- 11 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 app/models/conversation/cost.rb create mode 100644 app/models/conversation/cost_limited.rb diff --git a/app/assets/stylesheets/conversation.css b/app/assets/stylesheets/conversation.css index 0ee03fa59..71023984a 100644 --- a/app/assets/stylesheets/conversation.css +++ b/app/assets/stylesheets/conversation.css @@ -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); diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb index 0a6ec8b31..fc4b46838 100644 --- a/app/controllers/conversations/messages_controller.rb +++ b/app/controllers/conversations/messages_controller.rb @@ -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 diff --git a/app/javascript/controllers/conversation/composer_controller.js b/app/javascript/controllers/conversation/composer_controller.js index 368716846..0f7e83223 100644 --- a/app/javascript/controllers/conversation/composer_controller.js +++ b/app/javascript/controllers/conversation/composer_controller.js @@ -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 + ) + } } diff --git a/app/javascript/controllers/conversation/messages_controller.js b/app/javascript/controllers/conversation/messages_controller.js index 4d484ee2d..32e3e58e9 100644 --- a/app/javascript/controllers/conversation/messages_controller.js +++ b/app/javascript/controllers/conversation/messages_controller.js @@ -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 + } } } diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 184394fb1..afdb4b47c 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -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! diff --git a/app/models/conversation/cost.rb b/app/models/conversation/cost.rb new file mode 100644 index 000000000..d42568150 --- /dev/null +++ b/app/models/conversation/cost.rb @@ -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 diff --git a/app/models/conversation/cost_limited.rb b/app/models/conversation/cost_limited.rb new file mode 100644 index 000000000..537782b33 --- /dev/null +++ b/app/models/conversation/cost_limited.rb @@ -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 diff --git a/app/models/conversation/message.rb b/app/models/conversation/message.rb index dc817c500..282ec294d 100644 --- a/app/models/conversation/message.rb +++ b/app/models/conversation/message.rb @@ -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? diff --git a/app/models/conversation/message/response_generator/response.rb b/app/models/conversation/message/response_generator/response.rb index f404835a3..e3e0fcd41 100644 --- a/app/models/conversation/message/response_generator/response.rb +++ b/app/models/conversation/message/response_generator/response.rb @@ -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 diff --git a/test/models/conversation/response_generator/response_test.rb b/test/models/conversation/response_generator/response_test.rb index c33798b63..606d535cd 100644 --- a/test/models/conversation/response_generator/response_test.rb +++ b/test/models/conversation/response_generator/response_test.rb @@ -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 diff --git a/test/models/conversation_test.rb b/test/models/conversation_test.rb index 5691e7363..82de42d49 100644 --- a/test/models/conversation_test.rb +++ b/test/models/conversation_test.rb @@ -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