diff --git a/server/app/controllers/api/v1/connectors_controller.rb b/server/app/controllers/api/v1/connectors_controller.rb index 5c4c6a33c..8cec9a4f2 100644 --- a/server/app/controllers/api/v1/connectors_controller.rb +++ b/server/app/controllers/api/v1/connectors_controller.rb @@ -131,7 +131,9 @@ def execute_model if @connector.source? result = ExecuteModel.call( connector: @connector, - payload: params[:payload] + payload: params[:payload], + workspace: current_workspace, + user: current_user ) if result.success? @@ -140,7 +142,8 @@ def execute_model else render_error( message: result["error"], - status: :unprocessable_content + status: spend_limit_status(result), + metadata: result.metadata ) end else @@ -153,6 +156,10 @@ def execute_model private + def spend_limit_status(result) + SpendingLimits::SpendLimitExceeded.blocked?(result.metadata) ? :too_many_requests : :unprocessable_content + end + def set_connector @connector = current_workspace.connectors.find(params[:id]) rescue ActiveRecord::RecordNotFound diff --git a/server/app/mailers/spending_limit_mailer.rb b/server/app/mailers/spending_limit_mailer.rb new file mode 100644 index 000000000..19d643c8a --- /dev/null +++ b/server/app/mailers/spending_limit_mailer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SpendingLimitMailer < ApplicationMailer + default from: Rails.configuration.x.mail_from + + def threshold_alert_email + @alert_attrs = params + + mail( + to: params[:recipients], + subject: "Spend limit \"#{params[:limit_name]}\" reached #{params[:percentage]}%" + ) + end +end diff --git a/server/app/models/spending_limit.rb b/server/app/models/spending_limit.rb new file mode 100644 index 000000000..3f5eac680 --- /dev/null +++ b/server/app/models/spending_limit.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +class SpendingLimit < ApplicationRecord + MIN_ALERT_THRESHOLD = 1 + MAX_ALERT_THRESHOLD = 99 + DEFAULT_ALERT_THRESHOLDS = [80, 90].freeze + ALLOCATION_NAME = "Workspace allocation" + ALWAYS_ALERT_AT = 100 + # An empty list means "every one of them", so these carry no null state. + TARGET_FILTERS = %i[scope_ids providers models].freeze + MAX_TARGET_FILTER_ENTRIES = 100 + + belongs_to :organization + belongs_to :workspace + belongs_to :created_by, class_name: "User", optional: true + + has_many :spending_limit_counters, dependent: :destroy + + # Explicit values: index_spending_limits_one_allocation_per_workspace is partial on level = 1. + enum :level, { limit: 0, allocation: 1 }, scopes: false + enum :scope_type, %i[workspace workflow data_app user role] + enum :limit_type, %i[cost tokens both] + enum :period, %i[daily weekly monthly] + enum :action_on_exhaust, %i[warn block] + + before_validation :start_counting, on: :create + before_validation :normalize_alert_thresholds + before_validation :normalize_target_filters + + validates :name, presence: true, uniqueness: { scope: :workspace_id } + # The partial unique index is the real guarantee; this only turns the ordinary case into a 422. + validates :workspace_id, uniqueness: { conditions: -> { where(level: :allocation) }, + message: "already has an allocation" }, if: :allocation? + validates :cost_limit, presence: true, if: -> { cost? || both? } + validates :token_limit, presence: true, if: -> { tokens? || both? } + validates :cost_limit, numericality: { greater_than: 0 }, allow_nil: true + validates :token_limit, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates(*TARGET_FILTERS, length: { maximum: MAX_TARGET_FILTER_ENTRIES, + too_long: "has too many entries (maximum is #{MAX_TARGET_FILTER_ENTRIES})" }) + validate :alert_thresholds_in_range + validate :allocation_name_is_reserved + + def meters_cost? + cost? || both? + end + + def meters_tokens? + tokens? || both? + end + + def percent_used(snapshot) + percentages = [] + percentages << ratio(snapshot&.spent_cost || 0, cost_limit) if meters_cost? + percentages << ratio(snapshot&.spent_tokens || 0, token_limit) if meters_tokens? + percentages.compact.max || 0 + end + + # LedgerScope clamps its sum to this. + def restart_counting! + update_columns(counting_started_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + # Recovery reads this; settlement cannot, because a crossing is only visible in one upsert. + def thresholds_reached(counter) + used = percent_used(counter) + alerting_thresholds.select { |threshold| used >= threshold } + end + + def thresholds_crossed(before, after) + wanted = alerting_thresholds + from = percent_used(before) + to = percent_used(after) + wanted.select { |threshold| from < threshold && to >= threshold } + end + + scope :limits, -> { where(level: :limit) } + scope :allocations, -> { where(level: :allocation) } + scope :enabled, -> { where(enabled: true) } + scope :for_workspace, ->(workspace_id) { where(workspace_id:) } + + # The array type turns a lone string into [] before any callback sees it, so wrap in the writer. + TARGET_FILTERS.each do |field| + define_method(:"#{field}=") { |value| super(Array.wrap(value)) } + end + + private + + def alerting_thresholds + (Array(alert_thresholds).map(&:to_i) + [ALWAYS_ALERT_AT]).uniq.sort + end + + def start_counting + self.counting_started_at ||= Time.current + end + + def ratio(spent, cap) + return nil if cap.blank? || cap.to_d <= 0 + + (spent.to_d / cap.to_d) * 100 + end + + # [] means no early warnings and is kept; only nil takes the default. + def normalize_alert_thresholds + return self.alert_thresholds = DEFAULT_ALERT_THRESHOLDS.dup if alert_thresholds.nil? + return unless alert_thresholds.is_a?(Array) && alert_thresholds.all?(Numeric) + + self.alert_thresholds = alert_thresholds.map(&:to_i).uniq.sort + end + + def normalize_target_filters + TARGET_FILTERS.each do |field| + entries = Array.wrap(public_send(field)).map { |entry| entry.to_s.strip }.reject(&:empty?).uniq + public_send(:"#{field}=", entries) + end + end + + # The (workspace_id, name) index is level agnostic, so a limit may not take the allocation's name. + def allocation_name_is_reserved + return unless limit? + return unless name.to_s.strip.casecmp?(ALLOCATION_NAME) + + errors.add(:name, "is reserved for the workspace allocation") + end + + # The integer[] cast leaves a nested array alone, and that answers neither to_i nor between?. + def alert_thresholds_in_range + return errors.add(:alert_thresholds, "must be a list of numbers") unless alert_thresholds.is_a?(Array) + return if alert_thresholds.blank? + return errors.add(:alert_thresholds, "must be a list of numbers") unless alert_thresholds.all?(Integer) + return if alert_thresholds.all? { |threshold| threshold.between?(MIN_ALERT_THRESHOLD, MAX_ALERT_THRESHOLD) } + + errors.add(:alert_thresholds, "must be between #{MIN_ALERT_THRESHOLD} and #{MAX_ALERT_THRESHOLD}") + end +end diff --git a/server/app/models/spending_limit_alert.rb b/server/app/models/spending_limit_alert.rb new file mode 100644 index 000000000..96516b75a --- /dev/null +++ b/server/app/models/spending_limit_alert.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class SpendingLimitAlert < ApplicationRecord + # Configurable thresholds stop at 99; reaching the cap always alerts. + MAX_THRESHOLD = 100 + + belongs_to :spending_limit_counter + + validates :threshold, presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: SpendingLimit::MIN_ALERT_THRESHOLD, + less_than_or_equal_to: MAX_THRESHOLD }, + uniqueness: { scope: :spending_limit_counter_id } + + CLAIM_TTL = 15.minutes + + scope :pending, lambda { + where(delivered_at: nil, abandoned_at: nil) + .where("claimed_at IS NULL OR claimed_at < ?", CLAIM_TTL.ago) + } + + # ON CONFLICT, not a rescued create!: a uniqueness violation would abort the enclosing + # transaction and roll back the counter movement it shares. + def self.record_missing(counter, thresholds) + rows = Array(thresholds).map { |threshold| { spending_limit_counter_id: counter.id, threshold: threshold.to_i } } + return [] if rows.empty? + + result = insert_all( # rubocop:disable Rails/SkipsModelValidations + rows, unique_by: :index_spend_alerts_on_counter_and_threshold, returning: :id, record_timestamps: true + ) + result.rows.flatten + end + + # Only for a crossing that can never mean anything again; every other skip state can come back. + def abandon! + update!(abandoned_at: Time.current) + end + + def delivered? + delivered_at.present? + end +end diff --git a/server/app/models/spending_limit_counter.rb b/server/app/models/spending_limit_counter.rb new file mode 100644 index 000000000..9982f07a2 --- /dev/null +++ b/server/app/models/spending_limit_counter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class SpendingLimitCounter < ApplicationRecord + belongs_to :spending_limit + + # The FK cascade is what a window restart relies on; this covers only counter.destroy. + has_many :spending_limit_alerts, dependent: :delete_all + + validates :window_start, presence: true, uniqueness: { scope: :spending_limit_id } + validates :spent_cost, numericality: { greater_than_or_equal_to: 0 } + validates :spent_tokens, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :request_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :unpriced_requests, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + scope :recent, -> { order(window_start: :desc) } + scope :for_window, ->(window_start) { where(window_start:) } + + def alerted?(threshold) + alerted_thresholds.include?(threshold) + end + + # One statement, because a worker for another threshold writes the same column concurrently. + def record_threshold!(threshold) + value = threshold.to_i + append = ["alerted_thresholds = (SELECT array_agg(DISTINCT t ORDER BY t) " \ + "FROM unnest(alerted_thresholds || ?::integer) AS t)", value] + + self.class.where(id:) + .where.not("alerted_thresholds @> ARRAY[?]::integer[]", value) + .update_all(append) # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/server/app/services/spending_limits/spend_limit_exceeded.rb b/server/app/services/spending_limits/spend_limit_exceeded.rb new file mode 100644 index 000000000..faa86b543 --- /dev/null +++ b/server/app/services/spending_limits/spend_limit_exceeded.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module SpendingLimits + class SpendLimitExceeded < StandardError + CODE = "spend_limit_exceeded" + PUBLIC_MESSAGE = "This service is temporarily unavailable. Please try again later." + + SCOPE_LABELS = { + "workspace" => "Workspace", + "workflow" => "AI Workflows", + "data_app" => "Data Apps", + "user" => "Users", + "role" => "Roles" + }.freeze + + attr_reader :decision + + def self.blocked?(metadata) + return false unless metadata.is_a?(Hash) + + (metadata[:code] || metadata["code"]) == CODE + end + + def initialize(decision = nil) + @decision = decision + super(build_message) + end + + def limit + decision&.limit + end + + def resets_at + decision&.resets_at + end + + def scope + limit&.scope_type.to_s.presence + end + + def exhausted + decision&.exhausted.to_s.presence + end + + # No row id: this body reaches callers who may hold no spending_limit permission. + def metadata + { + code: CODE, + scope:, + exhausted:, + resets_at: resets_at&.utc&.iso8601 + }.compact + end + + def public_message + PUBLIC_MESSAGE + end + + private + + def build_message + return PUBLIC_MESSAGE if limit.nil? + + "Spend limit '#{limit.name}' (#{qualifiers}) is used up for the current period." + end + + def qualifiers + [SCOPE_LABELS[limit.scope_type.to_s] || limit.scope_type.to_s, provider_label].compact.join(", ") + end + + def provider_label + providers = Array(limit.providers).map(&:to_s).reject(&:empty?) + return nil if providers.empty? + + providers.to_sentence + end + end +end diff --git a/server/app/views/spending_limit_mailer/threshold_alert_email.html.erb b/server/app/views/spending_limit_mailer/threshold_alert_email.html.erb new file mode 100644 index 000000000..381168d08 --- /dev/null +++ b/server/app/views/spending_limit_mailer/threshold_alert_email.html.erb @@ -0,0 +1,111 @@ + + + + + + + + + + + +
+ + + + + + + + + + + +
+ AI Squared Logo +
+

+ Spend Limit at <%= @alert_attrs[:percentage] %>% +

+

+ <%= "The spend limit \"#{@alert_attrs[:limit_name]}\" has reached #{@alert_attrs[:percentage]}% of its #{@alert_attrs[:period]} budget." %> +

+ + + + + + + + + + + + + + + <% if @alert_attrs[:cost_limit].present? %> + + + + + <% end %> + <% if @alert_attrs[:token_limit].present? %> + + + + + <% end %> + <% if @alert_attrs[:resets_at].present? %> + + + + + <% end %> +
Limit Name<%= @alert_attrs[:limit_name] %>
Workspace<%= @alert_attrs[:workspace_name] %>
Reached<%= @alert_attrs[:percentage] %>%
Spent + <%= number_to_currency(@alert_attrs[:spent_cost] || 0, precision: 4) %> + of <%= number_to_currency(@alert_attrs[:cost_limit], precision: 4) %> +
Tokens + <%= number_with_delimiter(@alert_attrs[:spent_tokens].to_i) %> + of <%= number_with_delimiter(@alert_attrs[:token_limit].to_i) %> +
Period Resets<%= @alert_attrs[:resets_at].to_time.utc.strftime("%b %d, %Y %I:%M %p UTC") %>
+
+
AI Squared, 147 Castro Street, Second Floor, Mountain View, CA 94041
+
© <%= Time.current.year %> AI Squared. All Rights Reserved.
+
+
+ + diff --git a/server/db/migrate/20260919000100_add_counting_started_at_to_spending_limits.rb b/server/db/migrate/20260919000100_add_counting_started_at_to_spending_limits.rb new file mode 100644 index 000000000..0718b46e4 --- /dev/null +++ b/server/db/migrate/20260919000100_add_counting_started_at_to_spending_limits.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddCountingStartedAtToSpendingLimits < ActiveRecord::Migration[7.2] + # safety_assured: now() is STABLE, so PostgreSQL stores a fast default and does not rewrite. + def change + safety_assured do + add_column :spending_limits, :counting_started_at, :datetime, null: false, + default: -> { "CURRENT_TIMESTAMP" } + end + end +end diff --git a/server/db/migrate/20260919000200_backfill_spending_limit_counting_started_at.rb b/server/db/migrate/20260919000200_backfill_spending_limit_counting_started_at.rb new file mode 100644 index 000000000..9e22be0f4 --- /dev/null +++ b/server/db/migrate/20260919000200_backfill_spending_limit_counting_started_at.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class BackfillSpendingLimitCountingStartedAt < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + BATCH_SIZE = 1_000 + + # safety_assured: a batched, index-driven UPDATE outside a DDL transaction takes no long lock. + def up + safety_assured do + loop do + moved = execute(<<~SQL.squish).cmd_tuples + UPDATE spending_limits SET counting_started_at = created_at + WHERE id IN ( + SELECT id FROM spending_limits WHERE counting_started_at <> created_at LIMIT #{BATCH_SIZE} + ) + SQL + break if moved.zero? + end + end + end + + # The pre-backfill values were the deploy timestamp and carry no information. + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/server/db/migrate/20260919000300_create_spending_limit_alerts.rb b/server/db/migrate/20260919000300_create_spending_limit_alerts.rb new file mode 100644 index 000000000..0818a3afd --- /dev/null +++ b/server/db/migrate/20260919000300_create_spending_limit_alerts.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateSpendingLimitAlerts < ActiveRecord::Migration[7.2] + def change + create_table :spending_limit_alerts do |t| + # index: false, because the unique index below leads with this column and serves the cascade, + # which is load bearing: counters are removed with delete_all. + t.references :spending_limit_counter, null: false, foreign_key: { on_delete: :cascade }, index: false + t.integer :threshold, null: false + t.datetime :delivered_at + + t.timestamps + + t.index %i[spending_limit_counter_id threshold], unique: true, + name: "index_spend_alerts_on_counter_and_threshold" + t.index :id, where: "delivered_at IS NULL", name: "index_spend_alerts_pending" + end + end +end diff --git a/server/db/migrate/20260921120000_index_llm_usage_events_for_reconciliation.rb b/server/db/migrate/20260921120000_index_llm_usage_events_for_reconciliation.rb new file mode 100644 index 000000000..2653c65d9 --- /dev/null +++ b/server/db/migrate/20260921120000_index_llm_usage_events_for_reconciliation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# LedgerScope narrows by provider and by an id inside source_ref; neither is otherwise indexed. +class IndexLlmUsageEventsForReconciliation < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :llm_usage_events, + "workspace_id, lower(provider), created_at", + name: "index_llm_usage_events_on_workspace_and_lower_provider", + algorithm: :concurrently + + add_index :llm_usage_events, + "workspace_id, (source_ref ->> 'workflow_id'), created_at", + name: "index_llm_usage_events_on_workspace_and_workflow_ref", + where: "source_ref ? 'workflow_id'", + algorithm: :concurrently + + add_index :llm_usage_events, + "workspace_id, (source_ref ->> 'data_app_id'), created_at", + name: "index_llm_usage_events_on_workspace_and_data_app_ref", + where: "source_ref ? 'data_app_id'", + algorithm: :concurrently + end +end diff --git a/server/db/migrate/20260922090000_add_abandoned_at_to_spending_limit_alerts.rb b/server/db/migrate/20260922090000_add_abandoned_at_to_spending_limit_alerts.rb new file mode 100644 index 000000000..b8a9163e4 --- /dev/null +++ b/server/db/migrate/20260922090000_add_abandoned_at_to_spending_limit_alerts.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddAbandonedAtToSpendingLimitAlerts < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + PENDING_INDEX = "index_spend_alerts_pending" + + def up + add_column :spending_limit_alerts, :abandoned_at, :datetime + + # The sweep's query narrows on both columns, so the partial index has to as well. The old + # index is dropped only once its replacement is in place. + add_index :spending_limit_alerts, :id, where: "delivered_at IS NULL AND abandoned_at IS NULL", + name: "#{PENDING_INDEX}_v2", algorithm: :concurrently + remove_index :spending_limit_alerts, name: PENDING_INDEX, algorithm: :concurrently + end + + def down + add_index :spending_limit_alerts, :id, where: "delivered_at IS NULL", + name: PENDING_INDEX, algorithm: :concurrently + remove_index :spending_limit_alerts, name: "#{PENDING_INDEX}_v2", algorithm: :concurrently + remove_column :spending_limit_alerts, :abandoned_at + end +end diff --git a/server/db/migrate/20260922131000_add_claimed_at_to_spending_limit_alerts.rb b/server/db/migrate/20260922131000_add_claimed_at_to_spending_limit_alerts.rb new file mode 100644 index 000000000..b5169bf1d --- /dev/null +++ b/server/db/migrate/20260922131000_add_claimed_at_to_spending_limit_alerts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddClaimedAtToSpendingLimitAlerts < ActiveRecord::Migration[7.2] + def change + add_column :spending_limit_alerts, :claimed_at, :datetime + end +end diff --git a/server/db/schema.rb b/server/db/schema.rb index ad5a40d21..f535e8853 100644 --- a/server/db/schema.rb +++ b/server/db/schema.rb @@ -244,6 +244,205 @@ t.jsonb "additional_remarks" end +<<<<<<< HEAD +======= + create_table "git_installations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "user_id", null: false + t.string "provider", default: "github", null: false + t.bigint "external_id" + t.string "name" + t.integer "account_type" + t.string "status", default: "active", null: false + t.bigint "provider_user_id" + t.jsonb "provider_metadata", default: {}, null: false + t.text "cached_token" + t.datetime "token_expires_at" + t.text "user_token" + t.text "refresh_token" + t.datetime "refresh_token_expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["provider", "external_id", "user_id"], name: "index_git_installations_on_provider_external_id_and_user_id", unique: true, where: "(external_id IS NOT NULL)" + t.index ["provider", "provider_user_id"], name: "index_git_installations_on_provider_and_provider_user_id", where: "(provider_user_id IS NOT NULL)" + t.index ["provider", "user_id", "name"], name: "index_git_installations_on_provider_user_and_name", unique: true, where: "(name IS NOT NULL)" + t.index ["user_id", "provider"], name: "index_git_installations_pending_per_user", unique: true, where: "((status)::text = 'pending'::text)" + t.index ["user_id"], name: "index_git_installations_on_user_id" + end + + create_table "git_session_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "session_id", null: false + t.string "event", null: false + t.jsonb "metadata", default: {}, null: false + t.string "delivery_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["session_id", "delivery_id"], name: "index_git_session_events_on_session_and_delivery", unique: true, where: "(delivery_id IS NOT NULL)" + t.index ["session_id", "id"], name: "index_git_session_events_on_session_and_id" + t.index ["session_id"], name: "index_git_session_events_on_session_id" + end + + create_table "git_targets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "git_installation_id", null: false + t.string "repo", null: false + t.string "default_branch", null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["git_installation_id", "repo"], name: "index_git_targets_on_install_repo", unique: true + t.index ["git_installation_id"], name: "index_git_targets_on_git_installation_id" + t.index ["status"], name: "index_git_targets_on_status" + end + + create_table "git_webhook_deliveries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "delivery_id", null: false + t.string "scope", null: false + t.string "commit_sha" + t.string "status", default: "pending", null: false + t.string "webhook_event" + t.jsonb "webhook_payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["delivery_id"], name: "index_git_webhook_deliveries_on_delivery_id", unique: true + end + + create_table "hosted_data_store_tables", force: :cascade do |t| + t.integer "hosted_data_store_id" + t.string "name" + t.integer "column_count" + t.integer "row_count" + t.integer "size" + t.integer "sync_enabled" + t.integer "source_connector_id" + t.integer "destination_connector_id" + t.jsonb "table_schema", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "hosted_data_stores", force: :cascade do |t| + t.string "name" + t.integer "workspace_id" + t.integer "database_type" + t.text "description" + t.integer "state" + t.integer "source_connector_id" + t.integer "destination_connector_id" + t.string "template_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "knowledge_base_files", force: :cascade do |t| + t.string "name" + t.integer "size", default: 0 + t.integer "knowledge_base_id" + t.boolean "workflow_enabled", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "upload_status", default: 0 + end + + create_table "knowledge_bases", force: :cascade do |t| + t.string "name" + t.integer "knowledge_base_type" + t.integer "size", default: 0 + t.jsonb "embedding_config" + t.jsonb "storage_config" + t.integer "source_connector_id" + t.integer "destination_connector_id" + t.integer "hosted_data_store_id" + t.integer "workspace_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "parser_config", default: {"parser_provider"=>"langchain"}, null: false + end + + create_table "llm_routing_logs", force: :cascade do |t| + t.bigint "workspace_id", null: false + t.bigint "workflow_run_id", null: false + t.string "component_id", null: false + t.string "prompt_hash", null: false + t.string "selected_model", null: false + t.string "selected_component_id", null: false + t.string "selected_connector_id", null: false + t.string "routing_strategy", default: "judge_llm", null: false + t.string "optimization_mode", default: "balanced", null: false + t.text "routing_reason" + t.float "routing_confidence" + t.integer "execution_time_ms" + t.integer "routing_time_ms" + t.boolean "is_fallback", default: false + t.string "prompt_complexity" + t.string "task_type" + t.integer "estimated_tokens" + t.jsonb "routing_metadata", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["component_id"], name: "index_llm_routing_logs_on_component_id" + t.index ["created_at"], name: "index_llm_routing_logs_on_created_at" + t.index ["is_fallback"], name: "index_llm_routing_logs_on_is_fallback" + t.index ["prompt_complexity"], name: "index_llm_routing_logs_on_prompt_complexity" + t.index ["prompt_hash"], name: "index_llm_routing_logs_on_prompt_hash" + t.index ["selected_model"], name: "index_llm_routing_logs_on_selected_model" + t.index ["task_type"], name: "index_llm_routing_logs_on_task_type" + t.index ["workflow_run_id"], name: "index_llm_routing_logs_on_workflow_run_id" + t.index ["workspace_id"], name: "index_llm_routing_logs_on_workspace_id" + end + + create_table "llm_usage_events", force: :cascade do |t| + t.uuid "request_id", null: false + t.bigint "organization_id", null: false + t.bigint "workspace_id", null: false + t.bigint "user_id" + t.bigint "role_id" + t.integer "source", default: 0, null: false + t.jsonb "source_ref", default: {}, null: false + t.bigint "connector_id" + t.string "provider", null: false + t.string "model", null: false + t.bigint "input_tokens", default: 0, null: false + t.bigint "output_tokens", default: 0, null: false + t.bigint "reasoning_tokens", default: 0, null: false + t.bigint "cache_read_tokens", default: 0, null: false + t.bigint "cache_write_tokens", default: 0, null: false + t.integer "token_count_method", default: 0, null: false + t.decimal "cost", precision: 18, scale: 10 + t.bigint "pricing_id" + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.index "workspace_id, ((source_ref ->> 'data_app_id'::text)), created_at", name: "index_llm_usage_events_on_workspace_and_data_app_ref", where: "(source_ref ? 'data_app_id'::text)" + t.index "workspace_id, ((source_ref ->> 'workflow_id'::text)), created_at", name: "index_llm_usage_events_on_workspace_and_workflow_ref", where: "(source_ref ? 'workflow_id'::text)" + t.index "workspace_id, lower((provider)::text), created_at", name: "index_llm_usage_events_on_workspace_and_lower_provider" + t.index ["organization_id", "created_at"], name: "index_llm_usage_events_on_organization_id_and_created_at" + t.index ["request_id"], name: "index_llm_usage_events_on_request_id", unique: true + t.index ["workspace_id", "created_at"], name: "index_llm_usage_events_on_workspace_id_and_created_at" + t.index ["workspace_id", "source", "created_at"], name: "index_llm_usage_events_on_workspace_and_source_and_created_at" + t.check_constraint "cost IS NULL OR cost >= 0::numeric", name: "llm_usage_events_cost_non_negative" + t.check_constraint "input_tokens >= 0 AND output_tokens >= 0 AND reasoning_tokens >= 0 AND cache_read_tokens >= 0 AND cache_write_tokens >= 0", name: "llm_usage_events_token_counts_non_negative" + end + + create_table "llm_usage_logs", force: :cascade do |t| + t.bigint "workspace_id", null: false + t.bigint "workflow_run_id", null: false + t.string "component_id", null: false + t.string "connector_id", null: false + t.string "prompt_hash", null: false + t.integer "estimated_input_tokens", null: false + t.integer "estimated_output_tokens", null: false + t.string "selected_model", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "provider" + t.float "total_cost", default: 0.0, null: false + t.index ["component_id"], name: "index_llm_usage_logs_on_component_id" + t.index ["created_at"], name: "index_llm_usage_logs_on_created_at" + t.index ["prompt_hash"], name: "index_llm_usage_logs_on_prompt_hash" + t.index ["selected_model"], name: "index_llm_usage_logs_on_selected_model" + t.index ["workflow_run_id"], name: "index_llm_usage_logs_on_workflow_run_id" + t.index ["workspace_id"], name: "index_llm_usage_logs_on_workspace_id" + end + +>>>>>>> f7e57f5b5 (feat(CE): spending limit alerts schema, models and the refusal seam (#2267)) create_table "message_feedbacks", force: :cascade do |t| t.integer "workspace_id", null: false t.integer "data_app_id", null: false @@ -437,6 +636,66 @@ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end +<<<<<<< HEAD +======= + create_table "spending_limit_alerts", force: :cascade do |t| + t.bigint "spending_limit_counter_id", null: false + t.integer "threshold", null: false + t.datetime "delivered_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "abandoned_at" + t.datetime "claimed_at" + t.index ["id"], name: "index_spend_alerts_pending_v2", where: "((delivered_at IS NULL) AND (abandoned_at IS NULL))" + t.index ["spending_limit_counter_id", "threshold"], name: "index_spend_alerts_on_counter_and_threshold", unique: true + end + + create_table "spending_limit_counters", force: :cascade do |t| + t.bigint "spending_limit_id", null: false + t.datetime "window_start", null: false + t.decimal "spent_cost", precision: 18, scale: 10, default: "0.0", null: false + t.bigint "spent_tokens", default: 0, null: false + t.bigint "request_count", default: 0, null: false + t.integer "unpriced_requests", default: 0, null: false + t.integer "alerted_thresholds", default: [], null: false, array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["spending_limit_id", "window_start"], name: "index_spending_limit_counters_on_limit_and_window_start", unique: true + t.index ["spending_limit_id"], name: "index_spending_limit_counters_on_spending_limit_id" + t.check_constraint "spent_cost >= 0::numeric AND spent_tokens >= 0 AND request_count >= 0 AND unpriced_requests >= 0", name: "spending_limit_counters_totals_non_negative" + end + + create_table "spending_limits", force: :cascade do |t| + t.bigint "organization_id", null: false + t.bigint "workspace_id", null: false + t.integer "level", default: 0, null: false + t.string "name", null: false + t.integer "scope_type", default: 0, null: false + t.string "scope_ids", default: [], null: false, array: true + t.string "providers", default: [], null: false, array: true + t.string "models", default: [], null: false, array: true + t.integer "limit_type", default: 0, null: false + t.decimal "cost_limit", precision: 14, scale: 4 + t.bigint "token_limit" + t.integer "period", default: 0, null: false + t.integer "alert_thresholds", default: [80, 90], null: false, array: true + t.integer "action_on_exhaust", default: 0, null: false + t.boolean "enabled", default: true, null: false + t.bigint "created_by_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "counting_started_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.index ["created_by_id"], name: "index_spending_limits_on_created_by_id" + t.index ["organization_id"], name: "index_spending_limits_on_organization_id" + t.index ["workspace_id", "name"], name: "index_spending_limits_on_workspace_id_and_name", unique: true + t.index ["workspace_id"], name: "index_spending_limits_on_workspace_id" + t.index ["workspace_id"], name: "index_spending_limits_one_allocation_per_workspace", unique: true, where: "(level = 1)" + t.check_constraint "cost_limit IS NULL OR cost_limit > 0::numeric", name: "spending_limits_cost_limit_positive" + t.check_constraint "limit_type = 0 AND cost_limit IS NOT NULL OR limit_type = 1 AND token_limit IS NOT NULL OR limit_type = 2 AND cost_limit IS NOT NULL AND token_limit IS NOT NULL", name: "spending_limits_cap_matches_limit_type" + t.check_constraint "token_limit IS NULL OR token_limit > 0", name: "spending_limits_token_limit_positive" + end + +>>>>>>> f7e57f5b5 (feat(CE): spending limit alerts schema, models and the refusal seam (#2267)) create_table "sso_configurations", force: :cascade do |t| t.integer "organization_id" t.integer "status", default: 1 @@ -715,6 +974,14 @@ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +<<<<<<< HEAD +======= + add_foreign_key "spending_limit_alerts", "spending_limit_counters", on_delete: :cascade + add_foreign_key "spending_limit_counters", "spending_limits", on_delete: :cascade + add_foreign_key "spending_limits", "organizations" + add_foreign_key "spending_limits", "users", column: "created_by_id", on_delete: :nullify + add_foreign_key "spending_limits", "workspaces" +>>>>>>> f7e57f5b5 (feat(CE): spending limit alerts schema, models and the refusal seam (#2267)) add_foreign_key "taggings", "tags" add_foreign_key "workflow_integrations", "workflows", validate: false add_foreign_key "workflow_integrations", "workspaces", validate: false diff --git a/server/spec/factories/spending_limit_alerts.rb b/server/spec/factories/spending_limit_alerts.rb new file mode 100644 index 000000000..4c3bcfafe --- /dev/null +++ b/server/spec/factories/spending_limit_alerts.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :spending_limit_alert do + association :spending_limit_counter + + threshold { 80 } + delivered_at { nil } + + trait :delivered do + delivered_at { Time.current } + end + end +end diff --git a/server/spec/mailers/spending_limit_mailer_spec.rb b/server/spec/mailers/spending_limit_mailer_spec.rb new file mode 100644 index 000000000..52a9d33b7 --- /dev/null +++ b/server/spec/mailers/spending_limit_mailer_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SpendingLimitMailer, type: :mailer do + describe "#threshold_alert_email" do + let(:alert_attrs) do + { + recipients: ["test@ais.com"], + limit_id: 1, + limit_name: "Sales budget", + workspace_name: "Acme", + percentage: 80, + period: "monthly", + resets_at: Time.utc(2026, 10, 1), + spent_cost: BigDecimal("80.0"), + cost_limit: BigDecimal("100.0"), + spent_tokens: 0, + token_limit: nil, + action_on_exhaust: "block" + } + end + let(:mail) { described_class.with(alert_attrs).threshold_alert_email } + + it "renders the headers" do + expect(mail.subject).to eq('Spend limit "Sales budget" reached 80%') + expect(mail.to).to eq(["test@ais.com"]) + end + + it "sends from the configured address like the other mailers" do + expect(mail.from).to eq([Mail::Address.new(Rails.configuration.x.mail_from).address]) + end + + it "shows when the period resets" do + expect(mail.body.encoded).to match("Period Resets") + expect(mail.body.encoded).to match("Oct 01, 2026 12:00 AM UTC") + end + + it "renders without a reset time instead of raising" do + mail = described_class.with(alert_attrs.merge(resets_at: nil)).threshold_alert_email + + expect(mail.body.encoded).to match("Sales budget") + expect(mail.body.encoded).not_to match("Period Resets") + end + + it "renders the body" do + expect(mail.body.encoded).to match("Sales budget") + expect(mail.body.encoded).to match("Spend Limit at 80%") + expect(mail.body.encoded).to match("Acme") + end + + it "goes to every recipient it is given" do + mail = described_class.with(alert_attrs.merge(recipients: %w[one@ais.com two@ais.com])) + .threshold_alert_email + + expect(mail.to).to eq(%w[one@ais.com two@ais.com]) + end + + it "shows the spend against a cost limit and leaves the token row out" do + expect(mail.body.encoded).to match("Spent") + expect(mail.body.encoded).not_to match("Tokens") + end + + it "shows the token row for a limit measured in tokens" do + mail = described_class.with(alert_attrs.merge(cost_limit: nil, token_limit: 10_000, spent_tokens: 9000)) + .threshold_alert_email + + expect(mail.body.encoded).to match("Tokens") + expect(mail.body.encoded).to match("10,000") + end + end +end diff --git a/server/spec/models/spending_limit_alert_spec.rb b/server/spec/models/spending_limit_alert_spec.rb new file mode 100644 index 000000000..2bbf9ee0c --- /dev/null +++ b/server/spec/models/spending_limit_alert_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SpendingLimitAlert, type: :model do + let(:counter) { create(:spending_limit_counter) } + + describe "validations" do + it "is valid with a threshold inside the alert range" do + expect(build(:spending_limit_alert, spending_limit_counter: counter, threshold: 80)).to be_valid + end + + it "accepts 100, which is the cap itself rather than a configured threshold" do + expect(build(:spending_limit_alert, spending_limit_counter: counter, threshold: 100)).to be_valid + end + + it "refuses a threshold above the cap" do + alert = build(:spending_limit_alert, spending_limit_counter: counter, threshold: 101) + + expect(alert).not_to be_valid + expect(alert.errors[:threshold]).to be_present + end + + it "refuses a missing threshold rather than letting the not-null constraint raise" do + alert = build(:spending_limit_alert, spending_limit_counter: counter, threshold: nil) + + expect(alert).not_to be_valid + expect(alert.errors[:threshold]).to be_present + end + end + + describe "one row per counter and threshold" do + it "reads as a validation error rather than a database exception" do + create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) + + expect { create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) } + .to raise_error(ActiveRecord::RecordInvalid, /Threshold has already been taken/) + end + + # Two writers can both pass the validation, so the index has to be what actually refuses. + it "is still enforced by the database when the validation is bypassed" do + create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) + + expect { described_class.new(spending_limit_counter: counter, threshold: 80).save!(validate: false) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + + it "allows the same threshold on a different counter" do + other = create(:spending_limit_counter) + create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) + + expect(build(:spending_limit_alert, spending_limit_counter: other, threshold: 80)).to be_valid + end + end + + describe ".pending" do + it "returns only the rows that have not been delivered" do + undelivered = create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) + create(:spending_limit_alert, :delivered, spending_limit_counter: counter, threshold: 90) + + expect(described_class.pending).to contain_exactly(undelivered) + end + end + + describe "cleanup" do + it "goes when its counter goes, including through delete_all" do + alert = create(:spending_limit_alert, spending_limit_counter: counter, threshold: 80) + + SpendingLimitCounter.where(id: counter.id).delete_all + + expect(described_class.where(id: alert.id)).to be_empty + end + end +end diff --git a/server/spec/models/spending_limit_counter_spec.rb b/server/spec/models/spending_limit_counter_spec.rb new file mode 100644 index 000000000..e85965533 --- /dev/null +++ b/server/spec/models/spending_limit_counter_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SpendingLimitCounter, type: :model do + let(:workspace) { create(:workspace) } + let(:spending_limit) { create(:spending_limit, workspace:) } + + describe "associations" do + it { should belong_to(:spending_limit) } + end + + describe "validations" do + subject { build(:spending_limit_counter, spending_limit:) } + + it { should validate_presence_of(:window_start) } + it { should validate_numericality_of(:spent_cost).is_greater_than_or_equal_to(0) } + it { should validate_numericality_of(:spent_tokens).only_integer.is_greater_than_or_equal_to(0) } + it { should validate_numericality_of(:request_count).only_integer.is_greater_than_or_equal_to(0) } + it { should validate_numericality_of(:unpriced_requests).only_integer.is_greater_than_or_equal_to(0) } + + it "allows one counter per window of a limit" do + window = SpendingLimits::CounterStore.window_start(spending_limit.period) + create(:spending_limit_counter, spending_limit:, window_start: window) + duplicate = build(:spending_limit_counter, spending_limit:, window_start: window) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:window_start]).to include("has already been taken") + end + + it "leaves another limit free to use the same window" do + window = SpendingLimits::CounterStore.window_start(spending_limit.period) + create(:spending_limit_counter, spending_limit:, window_start: window) + other = create(:spending_limit, workspace:) + + expect(build(:spending_limit_counter, spending_limit: other, window_start: window)).to be_valid + end + end + + describe "#alerted?" do + it "is true only for a threshold already sent for this window" do + counter = build(:spending_limit_counter, spending_limit:, alerted_thresholds: [80]) + + expect(counter.alerted?(80)).to be(true) + expect(counter.alerted?(90)).to be(false) + end + + it "is false for every threshold on a fresh counter" do + expect(build(:spending_limit_counter, spending_limit:).alerted?(80)).to be(false) + end + end + + describe "scopes" do + it "orders the newest window first" do + window = SpendingLimits::CounterStore.window_start(spending_limit.period) + older = create(:spending_limit_counter, spending_limit:, window_start: window - 2.days) + newer = create(:spending_limit_counter, spending_limit:, window_start: window) + + expect(described_class.recent.to_a).to eq([newer, older]) + end + + it "returns only the counters of the given window" do + window = SpendingLimits::CounterStore.window_start(spending_limit.period) + wanted = create(:spending_limit_counter, spending_limit:, window_start: window) + create(:spending_limit_counter, spending_limit:, window_start: window - 1.day) + + expect(described_class.for_window(window)).to contain_exactly(wanted) + end + end + + describe "database constraints" do + it "refuses a negative total even when written around the model" do + counter = create(:spending_limit_counter, spending_limit:) + + negative = described_class.sanitize_sql(["UPDATE spending_limit_counters SET spent_cost = -1 WHERE id = ?", + counter.id]) + + expect { described_class.connection.update(negative) } + .to raise_error(ActiveRecord::StatementInvalid, /spending_limit_counters_totals_non_negative/) + end + end + + describe "deletion" do + it "goes with the limit it belongs to" do + create(:spending_limit_counter, spending_limit:) + + expect { spending_limit.destroy! }.to change(described_class, :count).by(-1) + end + end + + describe "#record_threshold!" do + it "appends the threshold" do + counter = create(:spending_limit_counter, alerted_thresholds: [80]) + + counter.record_threshold!(90) + + expect(counter.reload.alerted_thresholds).to eq([80, 90]) + end + + it "is idempotent, because two workers can settle the same crossing" do + counter = create(:spending_limit_counter, alerted_thresholds: [80]) + + 2.times { counter.record_threshold!(90) } + + expect(counter.reload.alerted_thresholds).to eq([80, 90]) + end + + it "keeps the list sorted and unique whatever order they arrive in" do + counter = create(:spending_limit_counter, alerted_thresholds: []) + + [90, 80, 100, 80].each { |threshold| counter.record_threshold!(threshold) } + + expect(counter.reload.alerted_thresholds).to eq([80, 90, 100]) + end + end +end diff --git a/server/spec/models/spending_limit_spec.rb b/server/spec/models/spending_limit_spec.rb new file mode 100644 index 000000000..a6c0442c2 --- /dev/null +++ b/server/spec/models/spending_limit_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SpendingLimit, type: :model do + let(:workspace) { create(:workspace) } + let(:user) { workspace.workspace_users.first.user } + + describe "associations" do + it { should belong_to(:organization) } + it { should belong_to(:workspace) } + it { should belong_to(:created_by).class_name("User").optional } + it { should have_many(:spending_limit_counters).dependent(:destroy) } + end + + describe "enums" do + it { should define_enum_for(:level).with_values(limit: 0, allocation: 1).without_scopes } + it { should define_enum_for(:scope_type).with_values(%i[workspace workflow data_app user role]) } + it { should define_enum_for(:limit_type).with_values(%i[cost tokens both]) } + it { should define_enum_for(:period).with_values(%i[daily weekly monthly]) } + it { should define_enum_for(:action_on_exhaust).with_values(%i[warn block]) } + + it "keeps the stored value of every scope" do + expect(described_class.scope_types).to eq( + "workspace" => 0, "workflow" => 1, "data_app" => 2, "user" => 3, "role" => 4 + ) + end + + it "writes the scope the column already held" do + limit = create(:spending_limit, workspace:, created_by: user, scope_type: :data_app, scope_ids: ["7"]) + + stored = described_class.connection.select_value( + described_class.sanitize_sql(["SELECT scope_type FROM spending_limits WHERE id = ?", limit.id]) + ) + + expect(stored).to eq(2) + end + end + + describe "the provider, model and scope lists" do + it "defaults every one of them to an empty list, which means all of them" do + limit = create(:spending_limit, workspace:, created_by: user) + + expect(limit.reload).to have_attributes(scope_ids: [], providers: [], models: []) + end + + it "drops blanks and duplicates and trims what it keeps" do + limit = create(:spending_limit, workspace:, created_by: user, + providers: ["OpenAI", "", " Anthropic ", "OpenAI"]) + + expect(limit.reload.providers).to eq(%w[OpenAI Anthropic]) + end + + it "turns a nil list back into an empty one" do + limit = create(:spending_limit, workspace:, created_by: user, models: nil) + + expect(limit.reload.models).to eq([]) + end + + it "stores scope ids as strings whatever it was given" do + limit = create(:spending_limit, workspace:, created_by: user, scope_type: :data_app, scope_ids: [7, "8"]) + + expect(limit.reload.scope_ids).to eq(%w[7 8]) + end + + it "wraps a single value into a list instead of dropping it" do + limit = create(:spending_limit, workspace:, created_by: user, providers: "OpenAI") + + expect(limit.reload.providers).to eq(%w[OpenAI]) + end + + it "refuses a list longer than the cap" do + too_many = (1..(described_class::MAX_TARGET_FILTER_ENTRIES + 1)).map(&:to_s) + limit = build(:spending_limit, workspace:, created_by: user, models: too_many) + + expect(limit).not_to be_valid + expect(limit.errors[:models]).to include("has too many entries (maximum is 100)") + end + end + + describe "validations" do + subject { build(:spending_limit, workspace:, created_by: user) } + + it { should validate_presence_of(:name) } + + context "when the limit is measured in cost" do + subject { build(:spending_limit, workspace:, created_by: user, limit_type: :cost) } + + it { should validate_presence_of(:cost_limit) } + it { should validate_numericality_of(:cost_limit).is_greater_than(0) } + it { should validate_numericality_of(:token_limit).only_integer.is_greater_than(0).allow_nil } + end + + context "when the limit is measured in tokens" do + subject { build(:spending_limit, :with_token_limit, workspace:, created_by: user) } + + it { should validate_presence_of(:token_limit) } + it { should validate_numericality_of(:token_limit).only_integer.is_greater_than(0) } + it { should validate_numericality_of(:cost_limit).is_greater_than(0).allow_nil } + end + + context "when the limit is measured in both" do + subject { build(:spending_limit, :with_both_limits, workspace:, created_by: user) } + + it { should validate_presence_of(:cost_limit) } + it { should validate_presence_of(:token_limit) } + end + end + + describe "the name the allocation row needs" do + it "is refused for an ordinary limit" do + limit = build(:spending_limit, workspace:, created_by: user, name: described_class::ALLOCATION_NAME) + + expect(limit).not_to be_valid + expect(limit.errors[:name]).to include("is reserved for the workspace allocation") + end + + it "is refused whatever the case or the padding" do + limit = build(:spending_limit, workspace:, created_by: user, name: " workspace ALLOCATION ") + + expect(limit).not_to be_valid + end + + it "is allowed for the allocation itself" do + expect(build(:spending_limit, :allocation, workspace:, created_by: user)).to be_valid + end + + it "leaves the allocation creatable next to ordinary limits" do + create(:spending_limit, workspace:, created_by: user, name: "Sales budget") + + expect(build(:spending_limit, :allocation, workspace:, created_by: user)).to be_valid + end + + it "still refuses two limits of one workspace sharing a name" do + create(:spending_limit, workspace:, created_by: user, name: "Sales budget") + duplicate = build(:spending_limit, workspace:, created_by: user, name: "Sales budget") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:name]).to include("has already been taken") + end + end + + describe "alert_thresholds" do + it "keeps an explicitly empty array, which means no early warnings" do + limit = create(:spending_limit, workspace:, created_by: user, alert_thresholds: []) + + expect(limit.reload.alert_thresholds).to eq([]) + end + + it "falls back to the column default rather than writing a null" do + limit = create(:spending_limit, workspace:, created_by: user, alert_thresholds: nil) + + expect(limit.reload.alert_thresholds).to eq(described_class::DEFAULT_ALERT_THRESHOLDS) + end + + it "sorts and dedupes what it is given" do + limit = create(:spending_limit, workspace:, created_by: user, alert_thresholds: [90, 80, 90]) + + expect(limit.alert_thresholds).to eq([80, 90]) + end + + it "refuses values outside the allowed range" do + limit = build(:spending_limit, workspace:, created_by: user, alert_thresholds: [0, 80]) + + expect(limit).not_to be_valid + expect(limit.errors[:alert_thresholds]).to include("must be between 1 and 99") + end + + it "refuses a nested list instead of raising while normalising it" do + limit = build(:spending_limit, workspace:, created_by: user, alert_thresholds: [[1]]) + + expect(limit).not_to be_valid + expect(limit.errors[:alert_thresholds]).to include("must be a list of numbers") + end + + it "refuses a list holding a blank" do + limit = build(:spending_limit, workspace:, created_by: user, alert_thresholds: [nil, 80]) + + expect(limit).not_to be_valid + expect(limit.errors[:alert_thresholds]).to include("must be a list of numbers") + end + end + + describe "database constraints" do + it "refuses a limit whose type has no matching cap, even written around the model" do + limit = create(:spending_limit, workspace:, created_by: user) + drop_cost = described_class.sanitize_sql(["UPDATE spending_limits SET cost_limit = NULL WHERE id = ?", limit.id]) + + expect { described_class.connection.update(drop_cost) } + .to raise_error(ActiveRecord::StatementInvalid, /spending_limits_cap_matches_limit_type/) + end + end + + describe "scopes" do + it "separates ordinary limits from the workspace allocation" do + limit = create(:spending_limit, workspace:, created_by: user) + allocation = create(:spending_limit, :allocation, workspace:, created_by: user) + + expect(described_class.limits).to contain_exactly(limit) + expect(described_class.allocations).to contain_exactly(allocation) + end + + it "returns only the enabled limits of the given workspace" do + enabled = create(:spending_limit, workspace:, created_by: user) + create(:spending_limit, :disabled, workspace:, created_by: user) + create(:spending_limit, workspace: create(:workspace)) + + expect(described_class.for_workspace(workspace.id).enabled).to contain_exactly(enabled) + end + end + describe "alert thresholds given a scalar" do + it "rejects a number instead of raising while normalising it" do + limit = build(:spending_limit) + limit.alert_thresholds = 80 + + expect(limit).not_to be_valid + expect(limit.errors[:alert_thresholds]).to include("must be a list of numbers") + end + end + + describe "#percent_used" do + it "reads against the cap the limit actually meters" do + limit = build(:spending_limit, limit_type: :cost, cost_limit: 200, token_limit: nil) + counter = build(:spending_limit_counter, spent_cost: 50, spent_tokens: 999_999) + + expect(limit.percent_used(counter)).to eq(25) + end + + it "takes the worse of the two when a limit meters both" do + limit = build(:spending_limit, :with_both_limits, cost_limit: 100, token_limit: 1000) + counter = build(:spending_limit_counter, spent_cost: 10, spent_tokens: 900) + + expect(limit.percent_used(counter)).to eq(90) + end + + it "is zero for a cap of nil rather than dividing by it" do + limit = build(:spending_limit, limit_type: :cost, cost_limit: nil, token_limit: nil) + + expect(limit.percent_used(build(:spending_limit_counter, spent_cost: 5))).to eq(0) + end + end + + describe "#thresholds_crossed" do + let(:limit) { build(:spending_limit, cost_limit: 100, alert_thresholds: [80, 90]) } + + it "names only the thresholds this movement passed" do + before = build(:spending_limit_counter, spent_cost: 70) + after = build(:spending_limit_counter, spent_cost: 92) + + expect(limit.thresholds_crossed(before, after)).to eq([80, 90]) + end + + it "says nothing for a movement that crosses none of them" do + before = build(:spending_limit_counter, spent_cost: 81) + after = build(:spending_limit_counter, spent_cost: 88) + + expect(limit.thresholds_crossed(before, after)).to be_empty + end + + it "always includes the cap itself, which is not configurable" do + before = build(:spending_limit_counter, spent_cost: 95) + after = build(:spending_limit_counter, spent_cost: 100) + + expect(limit.thresholds_crossed(before, after)).to eq([SpendingLimit::ALWAYS_ALERT_AT]) + end + end + + describe "#thresholds_reached" do + it "reads the standing total, not a movement, for the recovery path" do + limit = build(:spending_limit, cost_limit: 100, alert_thresholds: [80, 90]) + counter = build(:spending_limit_counter, spent_cost: 91) + + expect(limit.thresholds_reached(counter)).to eq([80, 90]) + end + end + + describe "#restart_counting!" do + it "moves the window the ledger sums from" do + limit = create(:spending_limit, counting_started_at: 3.days.ago) + + expect { limit.restart_counting! } + .to change { limit.reload.counting_started_at.to_date }.to(Time.zone.today) + end + end + + describe "counting_started_at on create" do + it "is set for a row that did not ask for one" do + expect(create(:spending_limit).counting_started_at).to be_present + end + + it "keeps a value the caller chose" do + chosen = 2.days.ago.change(usec: 0) + + expect(create(:spending_limit, counting_started_at: chosen).counting_started_at).to eq(chosen) + end + end + + describe "one allocation per workspace" do + it "refuses a second allocation with a validation rather than a constraint violation" do + workspace = create(:workspace) + create(:spending_limit, :allocation, workspace:) + + second = build(:spending_limit, :allocation, workspace:) + + expect(second).not_to be_valid + expect(second.errors[:workspace_id]).to include("already has an allocation") + end + + it "leaves ordinary limits alone" do + workspace = create(:workspace) + create(:spending_limit, :allocation, workspace:) + + expect(build(:spending_limit, workspace:)).to be_valid + end + end +end