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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ Spend Limit at <%= @alert_attrs[:percentage] %>%
+
+
+ <%= "The spend limit \"#{@alert_attrs[:limit_name]}\" has reached #{@alert_attrs[:percentage]}% of its #{@alert_attrs[:period]} budget." %>
+
+
+
+
+ | Limit Name |
+ <%= @alert_attrs[:limit_name] %> |
+
+
+ | Workspace |
+ <%= @alert_attrs[:workspace_name] %> |
+
+
+ | Reached |
+ <%= @alert_attrs[:percentage] %>% |
+
+ <% if @alert_attrs[:cost_limit].present? %>
+
+ | Spent |
+
+ <%= number_to_currency(@alert_attrs[:spent_cost] || 0, precision: 4) %>
+ of <%= number_to_currency(@alert_attrs[:cost_limit], precision: 4) %>
+ |
+
+ <% end %>
+ <% if @alert_attrs[:token_limit].present? %>
+
+ | Tokens |
+
+ <%= number_with_delimiter(@alert_attrs[:spent_tokens].to_i) %>
+ of <%= number_with_delimiter(@alert_attrs[:token_limit].to_i) %>
+ |
+
+ <% end %>
+ <% if @alert_attrs[:resets_at].present? %>
+
+ | Period Resets |
+ <%= @alert_attrs[:resets_at].to_time.utc.strftime("%b %d, %Y %I:%M %p UTC") %> |
+
+ <% end %>
+
+ |
+
+
+
+ |
+ 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