From d26c7ac48ded2f07e9e934cc4788239987bb2754 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Thu, 17 Apr 2025 15:09:48 -0700 Subject: [PATCH] FEATURE: Add spending metrics to AI usage (#1268) This update adds metrics for estimated spending in AI usage. To make use of it, admins must add cost details to the LLM config page (input, output, and cached input costs per 1M tokens). After doing so, the metrics will appear in the AI usage dashboard as the AI plugin is used. --- ...min-plugins-show-discourse-ai-llms-edit.js | 11 +- .../discourse_ai/admin/ai_llms_controller.rb | 3 + app/models/llm_model.rb | 12 +- app/serializers/ai_usage_serializer.rb | 10 + app/serializers/llm_model_serializer.rb | 3 + .../discourse/admin/models/ai-llm.js | 5 +- .../components/ai-llm-editor-form.gjs | 512 +++++++++--------- .../discourse/components/ai-usage.gjs | 61 ++- config/locales/client.en.yml | 10 + ...416215039_add_cost_metrics_to_llm_model.rb | 9 + lib/completions/llm.rb | 72 ++- lib/completions/report.rb | 74 ++- spec/fabricators/llm_model_fabricator.rb | 3 + .../admin/ai_usage_controller_spec.rb | 35 +- 14 files changed, 559 insertions(+), 261 deletions(-) create mode 100644 db/migrate/20250416215039_add_cost_metrics_to_llm_model.rb diff --git a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js index 8230211c..f7b5de4c 100644 --- a/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js +++ b/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-ai-llms-edit.js @@ -2,8 +2,17 @@ import DiscourseRoute from "discourse/routes/discourse"; export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute { async model(params) { - const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms"); const id = parseInt(params.id, 10); + + if (id < 0) { + // You shouldn't be able to access the edit page + // if the model is seeded + return this.router.transitionTo( + "adminPlugins.show.discourse-ai-llms.index" + ); + } + + const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms"); const record = allLlms.findBy("id", id); record.provider_params = record.provider_params || {}; return record; diff --git a/app/controllers/discourse_ai/admin/ai_llms_controller.rb b/app/controllers/discourse_ai/admin/ai_llms_controller.rb index 68ddcf94..84109220 100644 --- a/app/controllers/discourse_ai/admin/ai_llms_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_llms_controller.rb @@ -161,6 +161,9 @@ module DiscourseAi :api_key, :enabled_chat_bot, :vision_enabled, + :input_cost, + :cached_input_cost, + :output_cost, ) provider = updating ? updating.provider : permitted[:provider] diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb index 111ac70a..05c7be4b 100644 --- a/app/models/llm_model.rb +++ b/app/models/llm_model.rb @@ -13,7 +13,14 @@ class LlmModel < ActiveRecord::Base validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME } validates_presence_of :name, :api_key validates :max_prompt_tokens, numericality: { greater_than: 0 } - validates :max_output_tokens, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :input_cost, + :cached_input_cost, + :output_cost, + :max_output_tokens, + numericality: { + greater_than_or_equal_to: 0, + }, + allow_nil: true validate :required_provider_params scope :in_use, -> do @@ -184,5 +191,8 @@ end # enabled_chat_bot :boolean default(FALSE), not null # provider_params :jsonb # vision_enabled :boolean default(FALSE), not null +# input_cost :float +# cached_input_cost :float +# output_cost :float # max_output_tokens :integer # diff --git a/app/serializers/ai_usage_serializer.rb b/app/serializers/ai_usage_serializer.rb index 38969b05..0a990547 100644 --- a/app/serializers/ai_usage_serializer.rb +++ b/app/serializers/ai_usage_serializer.rb @@ -22,6 +22,9 @@ class AiUsageSerializer < ApplicationSerializer total_cached_tokens total_request_tokens total_response_tokens + input_spending + output_spending + cached_input_spending ], ) end @@ -35,6 +38,9 @@ class AiUsageSerializer < ApplicationSerializer total_cached_tokens total_request_tokens total_response_tokens + input_spending + output_spending + cached_input_spending ], ) end @@ -49,6 +55,9 @@ class AiUsageSerializer < ApplicationSerializer total_cached_tokens: user.total_cached_tokens, total_request_tokens: user.total_request_tokens, total_response_tokens: user.total_response_tokens, + input_spending: user.input_spending, + output_spending: user.output_spending, + cached_input_spending: user.cached_input_spending, } end end @@ -60,6 +69,7 @@ class AiUsageSerializer < ApplicationSerializer total_request_tokens: object.total_request_tokens, total_response_tokens: object.total_response_tokens, total_requests: object.total_requests, + total_spending: object.total_spending, date_range: { start: object.start_date, end: object.end_date, diff --git a/app/serializers/llm_model_serializer.rb b/app/serializers/llm_model_serializer.rb index 33e62d8b..736757d4 100644 --- a/app/serializers/llm_model_serializer.rb +++ b/app/serializers/llm_model_serializer.rb @@ -18,6 +18,9 @@ class LlmModelSerializer < ApplicationSerializer :enabled_chat_bot, :provider_params, :vision_enabled, + :input_cost, + :output_cost, + :cached_input_cost, :used_by has_one :user, serializer: BasicUserSerializer, embed: :object diff --git a/assets/javascripts/discourse/admin/models/ai-llm.js b/assets/javascripts/discourse/admin/models/ai-llm.js index c434ba25..cafbf3a0 100644 --- a/assets/javascripts/discourse/admin/models/ai-llm.js +++ b/assets/javascripts/discourse/admin/models/ai-llm.js @@ -15,7 +15,10 @@ export default class AiLlm extends RestModel { "api_key", "enabled_chat_bot", "provider_params", - "vision_enabled" + "vision_enabled", + "input_cost", + "cached_input_cost", + "output_cost" ); } diff --git a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs index 50ab2ff7..38cf1b50 100644 --- a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs +++ b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs @@ -47,6 +47,9 @@ export default class AiLlmEditorForm extends Component { name: modelInfo.name, provider: info.provider, provider_params: this.computeProviderParams(info.provider), + input_cost: modelInfo.input_cost, + output_cost: modelInfo.output_cost, + cached_input_cost: modelInfo.cached_input_cost, }; } @@ -63,6 +66,9 @@ export default class AiLlmEditorForm extends Component { provider: model.provider, enabled_chat_bot: model.enabled_chat_bot, vision_enabled: model.vision_enabled, + input_cost: model.input_cost, + output_cost: model.output_cost, + cached_input_cost: model.cached_input_cost, provider_params: this.computeProviderParams( model.provider, model.provider_params @@ -118,10 +124,6 @@ export default class AiLlmEditorForm extends Component { return localized.join(", "); } - get seeded() { - return this.args.model.id < 0; - } - get inUseWarning() { return i18n("discourse_ai.llms.in_use_warning", { settings: this.modulesUsingModel, @@ -271,15 +273,9 @@ export default class AiLlmEditorForm extends Component {
- {{#if this.seeded}} - - {{i18n "discourse_ai.llms.seeded_warning"}} - - {{/if}} - {{#if this.modulesUsingModel}} {{this.inUseWarning}} @@ -290,7 +286,6 @@ export default class AiLlmEditorForm extends Component { @name="display_name" @title={{i18n "discourse_ai.llms.display_name"}} @validation="required|length:1,100" - @disabled={{this.seeded}} @format="large" @tooltip={{i18n "discourse_ai.llms.hints.display_name"}} as |field| @@ -303,7 +298,6 @@ export default class AiLlmEditorForm extends Component { @title={{i18n "discourse_ai.llms.name"}} @tooltip={{i18n "discourse_ai.llms.hints.name"}} @validation="required" - @disabled={{this.seeded}} @format="large" as |field| > @@ -313,7 +307,6 @@ export default class AiLlmEditorForm extends Component { - {{#unless this.seeded}} - {{#if (this.canEditURL data.provider)}} - - - - {{/if}} - + {{#if (this.canEditURL data.provider)}} - + + {{/if}} - - {{#each (this.providerParamsKeys providerParamsData) as |name|}} - {{#let - (get (this.metaProviderParams data.provider) name) - as |params| - }} - - {{#if (eq params.type "enum")}} - - {{#each params.values as |option|}} - {{option.name}} - {{/each}} - - {{else if (eq params.type "checkbox")}} - - {{else}} - - {{/if}} - - {{/let}} - {{/each}} - + + + - - - {{#each this.tokenizers as |tokenizer|}} - {{tokenizer.name}} - {{/each}} - - - - - - - - - - - - - - - - - - - - {{#if @model.user}} - - + {{#each (this.providerParamsKeys providerParamsData) as |name|}} + {{#let + (get (this.metaProviderParams data.provider) name) + as |params| + }} + - {{Avatar @model.user.avatar_template "small"}} - - - {{@model.user.username}} - - - {{/if}} + {{#if (eq params.type "enum")}} + + {{#each params.values as |option|}} + {{option.name}} + {{/each}} + + {{else if (eq params.type "checkbox")}} + + {{else}} + + {{/if}} + + {{/let}} + {{/each}} + - {{#if (gt data.llm_quotas.length 0)}} - - - - - - - - - - - - - - - - - - - + + + + + +
{{i18n - "discourse_ai.llms.quotas.group" - }}{{i18n - "discourse_ai.llms.quotas.max_tokens" - }}{{i18n - "discourse_ai.llms.quotas.max_usages" - }}{{i18n - "discourse_ai.llms.quotas.duration" - }}
{{collectionData.group_name}} - - - - - - - - - - - - - - - + + {{#each this.tokenizers as |tokenizer|}} + {{tokenizer.name}} + {{/each}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#if @model.user}} + + + {{Avatar @model.user.avatar_template "small"}} + + + {{@model.user.username}} + + + {{/if}} + + {{#if (gt data.llm_quotas.length 0)}} + + + + + + + + + + + + + + + + - - -
{{i18n + "discourse_ai.llms.quotas.group" + }}{{i18n + "discourse_ai.llms.quotas.max_tokens" + }}{{i18n + "discourse_ai.llms.quotas.max_usages" + }}{{i18n + "discourse_ai.llms.quotas.duration" + }}
{{collectionData.group_name}} + + -
-
+ +
+ + + + + + + + + + + +
+
+ + {{/if}} + + + + + + + {{#if (eq data.llm_quotas.length 0)}} {{/if}} - + {{#unless @model.isNew}} - - - - {{#if (eq data.llm_quotas.length 0)}} - - {{/if}} - - {{#unless @model.isNew}} - - {{/unless}} - - {{/unless}} + {{/unless}} + {{#if this.displayTestResult}} diff --git a/assets/javascripts/discourse/components/ai-usage.gjs b/assets/javascripts/discourse/components/ai-usage.gjs index a972726b..0aaac6b2 100644 --- a/assets/javascripts/discourse/components/ai-usage.gjs +++ b/assets/javascripts/discourse/components/ai-usage.gjs @@ -2,6 +2,8 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { fn, hash } from "@ember/helper"; import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import { LinkTo } from "@ember/routing"; import { service } from "@ember/service"; import { eq, gt, lt } from "truth-helpers"; @@ -74,6 +76,22 @@ export default class AiUsage extends Component { this.onFilterChange(); } + @action + addCurrencyChar(element) { + element.querySelectorAll(".d-stat-tile__label").forEach((label) => { + if ( + label.innerText.trim() === i18n("discourse_ai.usage.total_spending") + ) { + const valueElement = label + .closest(".d-stat-tile") + ?.querySelector(".d-stat-tile__value"); + if (valueElement) { + valueElement.innerText = `$${valueElement.innerText}`; + } + } + }); + } + @bind takeUsers(start, end) { return this.data.users.slice(start, end); @@ -153,6 +171,11 @@ export default class AiUsage extends Component { value: this.data.summary.total_cached_tokens, tooltip: i18n("discourse_ai.usage.stat_tooltips.cached_tokens"), }, + { + label: i18n("discourse_ai.usage.total_spending"), + value: this.data.summary.total_spending, + tooltip: i18n("discourse_ai.usage.stat_tooltips.total_spending"), + }, ]; } @@ -308,6 +331,11 @@ export default class AiUsage extends Component { this.fetchData(); } + totalSpending(inputSpending, cachedSpending, outputSpending) { + const total = inputSpending + cachedSpending + outputSpending; + return `$${total.toFixed(2)}`; + } +