Compare commits

...

17 Commits

Author SHA1 Message Date
Anderson
e360539da9 Small UI improvements 2026-04-22 13:29:57 -03:00
Anderson
11564afcc4 Merge branch 'master' into feat/usage-indicator 2026-04-22 13:21:15 -03:00
Anderson
8f747ffc36 Merge branch 'master' into feat/usage-indicator 2026-04-21 01:43:37 -03:00
Anderson
84082f8ee8 Update usage indicator positioning and color as per Design review 2026-04-21 00:58:08 -03:00
Anderson
15a533085d Fix unit test 2026-04-20 14:20:40 -03:00
Anderson
678b8f7ab4 Tiny fix 2026-04-20 11:57:10 -03:00
Anderson
7c702ee912 Merge branch 'master' into feat/usage-indicator 2026-04-17 20:48:04 -03:00
Anderson
49edb8c0ef Fix unit test 2026-04-16 22:37:39 -03:00
Anderson
a549906de5 Merge branch 'master' into feat/usage-indicator 2026-04-16 20:53:06 -03:00
Anderson
f09746a43f Some component reorganization and small UI adjusts 2026-04-16 20:52:36 -03:00
Edward Paget
c5c471f9ab Refresh usage data after metabot finishes responding
Invalidate the metabot-permissions query tag after sendAgentRequest
completes so the usage indicator updates with current token counts.
2026-04-08 14:11:27 -07:00
Edward Paget
cc1e36aebd Fix kondo warnings for contains? on potentially nil summary 2026-04-08 13:47:54 -07:00
Edward Paget
33d3fe3f4a Use RingProgress for compact usage indicator
Switch the compact MetabotUsageIndicator from a linear Progress bar to
a circular RingProgress with a visible track so it reads as a filling
ring rather than a floating arc.
2026-04-08 13:43:02 -07:00
Edward Paget
b4d90ceae7 Fix kondo warnings in usage_test.clj
Add *warn-on-reflection* to suppress reflection warning on
Instant/parse. Add some? guard before contains? checks to satisfy
kondo's nil-safety analysis.
2026-04-08 13:32:34 -07:00
Edward Paget
fef9187e1a Integrate usage indicator into sidebar and explorations page
Place compact usage indicator in the metabot sidebar header (left of
reset button) and detailed variant below suggestions on the AI
explorations page. Token usage now reports decimals in millions
(e.g. 1.5M tokens). Detailed variant uses subtle styling with smaller
text and tighter spacing to avoid dominating the page layout.

Done: llm-usage-indicator/phase-3-integrate-indicator
2026-04-08 13:18:43 -07:00
Edward Paget
33d4ffd1a8 Add frontend usage hook and indicator component
Add MetabotUsage type, useMetabotUsage hook with independent user/pool
scope objects (usage, limit, percent, thresholds), and
MetabotUsageIndicator component with compact and detailed variants.
The hook computes mostConstrained to identify which scope is closest
to its limit. Both variants show normal/warning/at-limit visual states
and return null when no limits are configured.

Done: llm-usage-indicator/phase-2-frontend-usage-hook-component
2026-04-08 12:43:37 -07:00
Edward Paget
383d755cb9 Add usage data to /user-permissions metabot endpoint
Return user, instance, and tenant LLM usage and limits from the
/api/metabot/permissions/user-permissions endpoint to power frontend
usage indicators. The EE implementation queries ai_usage_log for
current-period usage and resolves limits from instance/group/tenant
tables. OSS returns nil.

Done: llm-usage-indicator/phase-1-backend-usage-endpoint
2026-04-08 11:47:57 -07:00
25 changed files with 778 additions and 76 deletions

View File

@@ -64,11 +64,11 @@
base-where)]
(case limit-type
:tokens
(quot (or (:sum (t2/query-one {:select [[[:sum :total_tokens] :sum]]
:from [:ai_usage_log]
:where full-where}))
0)
1000000)
(let [raw (or (:sum (t2/query-one {:select [[[:sum :total_tokens] :sum]]
:from [:ai_usage_log]
:where full-where}))
0)]
(/ (Math/round (/ (double raw) 100000.0)) 10.0))
:messages
(:cnt (t2/query-one {:select [[[:count :*] :cnt]]
@@ -145,3 +145,22 @@
(or (check-instance-limit* :instance)
(check-tenant-limit* tenant-id)
(check-user-limit* user-id))))
(defenterprise usage-summary
"Return a usage summary map for the current user showing current usage and applicable limits."
:feature :ai-controls
[]
(let [user-id api/*current-user-id*
tenant-id (some-> api/*current-user* deref :tenant_id)
limit-unit (metabot.settings/metabot-limit-unit)
reset-rate (metabot.settings/metabot-limit-reset-rate)
start (period-start)]
(cond-> {:user_usage (usage-query [[:= :user_id user-id]])
:user_limit (group-limit/limit-for-user user-id)
:instance_usage (usage-query [])
:instance_limit (:max_usage (instance-limit/instance-limit nil))
:limit_unit (name limit-unit)
:limit_reset_rate (name reset-rate)
:period_start (str start)}
tenant-id (assoc :tenant_usage (usage-query [[:= :tenant_id tenant-id]])
:tenant_limit (:max_usage (instance-limit/instance-limit tenant-id))))))

View File

@@ -85,6 +85,33 @@
(let [perms (:permissions (mt/user-http-request :rasta :get 200 "metabot/permissions/user-permissions"))]
(is (= "yes" (:metabot-sql-generation perms)))))))))
(deftest user-permissions-includes-usage-test
(mt/with-premium-features #{:ai-controls}
(t2/delete! :model/MetabotGroupLimit)
(t2/delete! :model/MetabotInstanceLimit)
(t2/delete! :model/AiUsageLog)
(mt/with-temporary-setting-values [metabot-limit-unit "tokens"
metabot-limit-reset-rate "monthly"]
(testing "GET /api/metabot/permissions/user-permissions includes usage data in EE"
(let [usage (:usage (mt/user-http-request :rasta :get 200 "metabot/permissions/user-permissions"))]
(is (= {:user_usage 0.0
:user_limit nil
:instance_usage 0.0
:instance_limit nil
:limit_unit "tokens"
:limit_reset_rate "monthly"}
(dissoc usage :period_start)))
(is (string? (:period_start usage)))))
(testing "superuser also gets usage data"
(let [usage (:usage (mt/user-http-request :crowberto :get 200 "metabot/permissions/user-permissions"))]
(is (= {:user_usage 0.0
:user_limit nil
:instance_usage 0.0
:instance_limit nil
:limit_unit "tokens"
:limit_reset_rate "monthly"}
(dissoc usage :period_start))))))))
(deftest ^:parallel admin-endpoints-require-ai-controls-feature-test
(testing "admin endpoints return 402 without :ai-controls feature"
(mt/with-premium-features #{}

View File

@@ -5,7 +5,11 @@
[metabase.api.common :as api]
[metabase.metabot.usage :as usage]
[metabase.test :as mt]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(java.time Instant)))
(set! *warn-on-reflection* true)
(defn- insert-usage!
"Insert a test ai_usage_log row for the given user with the specified total_tokens."
@@ -371,3 +375,145 @@
(is (= "test limit reached" (usage/check-usage-limits!))))
(finally
(cleanup-test-usage! user-id)))))))))
;;; ------------------------------------------ usage-summary ------------------------------------------
(defn- clean-usage-state!
"Remove all limits and usage logs to ensure test isolation."
[]
(ee.usage/clear-limit-cache!)
(t2/delete! :model/MetabotGroupLimit)
(t2/delete! :model/MetabotInstanceLimit)
(t2/delete! :model/AiUsageLog))
(deftest usage-summary-no-limits-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "tokens"
metabot-limit-reset-rate "monthly"]
(testing "usage-summary returns usage data with nil limits when no limits configured"
(let [user-id (mt/user->id :rasta)]
(insert-usage! user-id 5000000)
(try
(mt/with-test-user :rasta
(let [summary (usage/usage-summary)]
(is (= {:user_usage 5.0
:user_limit nil
:instance_usage 5.0
:instance_limit nil
:limit_unit "tokens"
:limit_reset_rate "monthly"}
(dissoc summary :period_start)))
(is (string? (:period_start summary)))
(is (inst? (Instant/parse (:period_start summary))))))
(finally
(cleanup-test-usage! user-id))))))))
(deftest usage-summary-with-instance-limit-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "tokens"
metabot-limit-reset-rate "monthly"]
(testing "usage-summary includes instance limit when configured"
(mt/with-temp [:model/MetabotInstanceLimit _ {:tenant_id nil :max_usage 500}]
(let [user-id (mt/user->id :rasta)]
(insert-usage! user-id 10000000)
(try
(mt/with-test-user :rasta
(is (= {:instance_usage 10.0
:instance_limit 500}
(select-keys (usage/usage-summary) [:instance_usage :instance_limit]))))
(finally
(cleanup-test-usage! user-id)))))))))
(deftest usage-summary-with-group-limit-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "tokens"
metabot-limit-reset-rate "monthly"]
(testing "usage-summary includes user limit from group"
;; Set limits on all groups the user belongs to, otherwise limit-for-user returns nil
;; (any group without a limit means unlimited)
(let [user-id (mt/user->id :rasta)
group-ids (t2/select-fn-vec :group_id :model/PermissionsGroupMembership :user_id user-id)]
(doseq [gid group-ids]
(t2/insert! :model/MetabotGroupLimit {:group_id gid :max_usage 200}))
(insert-usage! user-id 10000000)
(try
(mt/with-test-user :rasta
(is (= {:user_usage 10.0
:user_limit 200}
(select-keys (usage/usage-summary) [:user_usage :user_limit]))))
(finally
(cleanup-test-usage! user-id)
(doseq [gid group-ids]
(t2/delete! :model/MetabotGroupLimit :group_id gid)))))))))
(deftest usage-summary-with-tenant-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "tokens"
metabot-limit-reset-rate "monthly"]
(testing "usage-summary includes tenant usage and limit when user has a tenant"
(mt/with-temp [:model/Tenant {tenant-id :id} {}
:model/MetabotInstanceLimit _ {:tenant_id tenant-id :max_usage 300}]
(let [user-id (mt/user->id :rasta)]
(insert-usage! user-id 20000000 tenant-id)
(try
(binding [api/*current-user-id* user-id
api/*current-user* (delay {:tenant_id tenant-id})]
(is (= {:tenant_usage 20.0
:tenant_limit 300}
(select-keys (usage/usage-summary) [:tenant_usage :tenant_limit]))))
(finally
(cleanup-test-usage! user-id)))))))))
(deftest usage-summary-messages-limit-unit-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "messages"]
(testing "usage-summary counts messages (rows) instead of tokens when limit unit is messages"
(let [user-id (mt/user->id :rasta)]
(dotimes [_ 3]
(insert-usage! user-id 5000000))
(try
(mt/with-test-user :rasta
(is (= {:user_usage 3
:user_limit nil
:limit_unit "messages"}
(select-keys (usage/usage-summary) [:user_usage :user_limit :limit_unit]))))
(finally
(cleanup-test-usage! user-id))))))))
(deftest usage-summary-messages-with-instance-limit-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(mt/with-temporary-setting-values [metabot-limit-unit "messages"]
(testing "usage-summary with messages mode respects instance limits"
(mt/with-temp [:model/MetabotInstanceLimit _ {:tenant_id nil :max_usage 10}]
(let [user-id (mt/user->id :rasta)]
(dotimes [_ 5]
(insert-usage! user-id 1))
(try
(mt/with-test-user :rasta
(is (= {:instance_usage 5
:instance_limit 10
:limit_unit "messages"}
(select-keys (usage/usage-summary) [:instance_usage :instance_limit :limit_unit]))))
(finally
(cleanup-test-usage! user-id)))))))))
(deftest usage-summary-no-tenant-keys-test
(mt/with-premium-features #{:ai-controls}
(clean-usage-state!)
(testing "usage-summary omits tenant keys when user has no tenant"
(let [user-id (mt/user->id :rasta)]
(insert-usage! user-id 1000000)
(try
(mt/with-test-user :rasta
(let [summary (usage/usage-summary)]
(is (map? summary))
(is (not (contains? (or summary {}) :tenant_usage)))
(is (not (contains? (or summary {}) :tenant_limit)))))
(finally
(cleanup-test-usage! user-id)))))))

View File

@@ -343,8 +343,21 @@ export type UserMetabotPermissions = {
"metabot-other-tools": "yes" | "no";
};
export type MetabotUsage = {
user_usage: number; // tokens: in millions (e.g. 1.5 = 1.5M tokens); messages: integer count
user_limit: number | null;
instance_usage: number;
instance_limit: number | null;
tenant_usage?: number;
tenant_limit?: number | null;
limit_unit: MetabotLimitType;
limit_reset_rate: MetabotLimitPeriod;
period_start: string;
};
export type UserMetabotPermissionsResponse = {
permissions: UserMetabotPermissions;
usage: MetabotUsage | null;
};
export type MetabotLimitPeriod = "daily" | "weekly" | "monthly";

View File

@@ -2,6 +2,7 @@ import {
AIToolKey,
type MetabotGroupPermission,
type MetabotInfo,
type MetabotUsage,
type UserMetabotPermissions,
type UserMetabotPermissionsResponse,
} from "../metabot";
@@ -20,18 +21,35 @@ export const createMockMetabotInfo = (
...opts,
});
export const createMockUserMetabotPermissions = (
opts?: Partial<UserMetabotPermissions>,
): UserMetabotPermissionsResponse => ({
permissions: {
metabot: "yes",
"metabot-sql-generation": "yes",
"metabot-nlq": "yes",
"metabot-other-tools": "yes",
...opts,
},
export const createMockMetabotUsage = (
opts?: Partial<MetabotUsage>,
): MetabotUsage => ({
user_usage: 0,
user_limit: null,
instance_usage: 0,
instance_limit: null,
limit_unit: "tokens",
limit_reset_rate: "monthly",
period_start: new Date().toISOString(),
...opts,
});
export const createMockUserMetabotPermissions = (
opts?: Partial<UserMetabotPermissions> & { usage?: MetabotUsage | null },
): UserMetabotPermissionsResponse => {
const { usage = null, ...permOpts } = opts ?? {};
return {
permissions: {
metabot: "yes",
"metabot-sql-generation": "yes",
"metabot-nlq": "yes",
"metabot-other-tools": "yes",
...permOpts,
},
usage,
};
};
export const createMockMetabotGroupPermission = (
opts?: Partial<MetabotGroupPermission>,
): MetabotGroupPermission => ({

View File

@@ -1,7 +1,7 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupEnterprisePlugins } from "__support__/enterprise";
import { setupUserMetabotPermissionsEndpoint } from "__support__/server-mocks/metabot";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { createMockState } from "metabase/redux/store/mocks";
@@ -19,8 +19,7 @@ function setup({
isMetabotEnabled?: boolean;
permissionOverrides?: Partial<UserMetabotPermissions>;
} = {}) {
fetchMock.get(
"path:/api/metabot/permissions/user-permissions",
setupUserMetabotPermissionsEndpoint(
createMockUserMetabotPermissions(permissionOverrides),
);
@@ -61,13 +60,11 @@ describe("MetabotAppBarButton", () => {
it("should not render the button when user lacks metabot permission", async () => {
setup({ permissionOverrides: { metabot: "no" } });
// Wait for the API response to be processed
await waitFor(() => {
expect(fetchMock.callHistory.calls().length).toBeGreaterThan(0);
expect(
screen.queryByRole("button", { name: /Chat with Metabot/ }),
).not.toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /Chat with Metabot/ }),
).not.toBeInTheDocument();
});
it("should toggle metabot visibility when clicked", async () => {

View File

@@ -88,7 +88,7 @@ export const MetabotChat = ({
</Text>
</Flex>
<Flex gap="sm">
<Flex gap="sm" align="center">
<Tooltip label={t`Clear conversation`} position="bottom">
<ActionIcon
onClick={handleResetChat}

View File

@@ -13,13 +13,17 @@
justify-content: center;
}
.actions {
display: flex;
align-items: center;
align-self: center;
gap: 0.25rem;
}
.button {
margin-bottom: 0.625rem;
margin-right: -0.125rem;
background: var(--mb-color-brand);
border-radius: 50%;
align-self: flex-end;
margin-left: 2px;
width: 1.25rem;
height: 1.25rem;
display: flex;
@@ -38,17 +42,16 @@
&:active {
transform: scale(0.9);
}
}
.buttonHidden {
opacity: 0;
transition: opacity 0ms ease;
user-select: none;
cursor: default;
&:disabled {
opacity: 0.35;
cursor: default;
&:hover,
&:active {
opacity: 0;
&:hover,
&:active {
opacity: 0.35;
transform: none;
}
}
}

View File

@@ -7,6 +7,7 @@ import {
MetabotPromptInput,
type MetabotPromptInputProps,
} from "metabase/metabot/components/MetabotPromptInput";
import { MetabotUsageIndicator } from "metabase/metabot/components/MetabotUsageIndicator/MetabotUsageIndicator";
import { Box, Icon, UnstyledButton } from "metabase/ui";
import S from "./MetabotChatEditor.module.css";
@@ -26,6 +27,8 @@ export const MetabotChatEditor = forwardRef<
MetabotPromptInputRef | null,
MetabotChatEditorProps
>(({ isResponding = false, ...props }, ref) => {
const sendDisabled = props.value.length === 0 && !isResponding;
return (
<Box className={S.editorContainer}>
<Box className={S.iconContainer}>
@@ -39,23 +42,23 @@ export const MetabotChatEditor = forwardRef<
data-testid="metabot-chat-input"
/>
</Box>
<UnstyledButton
className={cx(
S.button,
isResponding && S.buttonResponding,
props.value.length === 0 && !isResponding && S.buttonHidden,
)}
onClick={isResponding ? props.onStop : props.onSubmit}
data-testid={
isResponding ? "metabot-stop-response" : "metabot-send-message"
}
>
{isResponding ? (
<Icon className={S.stopIcon} name="stop" />
) : (
<Icon className={S.sendIcon} name="arrow_up" />
)}
</UnstyledButton>
<Box className={S.actions}>
<MetabotUsageIndicator />
<UnstyledButton
className={cx(S.button, isResponding && S.buttonResponding)}
disabled={sendDisabled}
onClick={isResponding ? props.onStop : props.onSubmit}
data-testid={
isResponding ? "metabot-stop-response" : "metabot-send-message"
}
>
{isResponding ? (
<Icon className={S.stopIcon} name="stop" />
) : (
<Icon className={S.sendIcon} name="arrow_up" />
)}
</UnstyledButton>
</Box>
</Box>
);
});

View File

@@ -91,6 +91,10 @@
}
}
.buttonLabel {
display: inline-flex;
}
.promptSuggestionsContainer {
display: flex;
flex-direction: column;

View File

@@ -14,6 +14,7 @@ import { useRouter } from "metabase/router";
import {
Box,
Button,
Group,
Icon,
Paper,
Stack,
@@ -24,6 +25,7 @@ import { useDispatch } from "metabase/utils/redux";
import * as Urls from "metabase/utils/urls";
import { useMetabotAgent, useUserMetabotPermissions } from "../../hooks";
import { MetabotUsageIndicator } from "../MetabotUsageIndicator/MetabotUsageIndicator";
import S from "./MetabotQueryBuilder.module.css";
@@ -205,16 +207,22 @@ const MetabotQueryBuilderInner = () => {
) : (
<div />
)}
<Button
className={S.sendButton}
variant="filled"
disabled={inputDisabled}
loading={isDoingScience}
onClick={handleEditorSubmit}
data-testid="metabot-send-message"
>
<Icon name="arrow_up" />
</Button>
<Group gap="sm" align="center">
<MetabotUsageIndicator />
<Button
variant="filled"
disabled={inputDisabled}
loading={isDoingScience}
onClick={handleEditorSubmit}
data-testid="metabot-send-message"
classNames={{
root: S.sendButton,
label: S.buttonLabel,
}}
>
<Icon name="arrow_up" />
</Button>
</Group>
</Box>
</Paper>

View File

@@ -0,0 +1,3 @@
.actionIcon {
cursor: default;
}

View File

@@ -0,0 +1,43 @@
import { t } from "ttag";
import { useMetabotUsage } from "metabase/metabot/hooks";
import { ActionIcon, Indicator, RingProgress, Tooltip } from "metabase/ui";
import S from "./MetabotUsageIndicator.module.css";
import { formatUsageText } from "./utils";
export function MetabotUsageIndicator() {
const { user, pool, mostConstrained, limitUnit } = useMetabotUsage();
const otherScope = mostConstrained === user ? pool : user;
const showSecondaryWarning = otherScope?.isNearLimit ?? false;
if (!mostConstrained) {
return null;
}
return (
<Tooltip label={formatUsageText(mostConstrained, limitUnit)}>
<ActionIcon className={S.actionIcon} role="status" component="div">
<Indicator
disabled={!showSecondaryWarning}
color="warning"
size={5}
offset={1}
>
<RingProgress
size={24}
thickness={3}
rootColor="border"
sections={[
{
value: mostConstrained.percent,
color: "border-strong",
},
]}
aria-label={t`AI usage`}
/>
</Indicator>
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,81 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { setupUserMetabotPermissionsEndpoint } from "__support__/server-mocks/metabot";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockState } from "metabase/redux/store/mocks";
import type { MetabotUsage } from "metabase-types/api";
import {
createMockMetabotUsage,
createMockUserMetabotPermissions,
} from "metabase-types/api/mocks";
import { MetabotUsageIndicator } from "./MetabotUsageIndicator";
function setup({
usage = null,
}: {
usage?: MetabotUsage | null;
} = {}) {
setupUserMetabotPermissionsEndpoint(
createMockUserMetabotPermissions({ usage }),
);
const settings = mockSettings({
"llm-metabot-configured?": true,
"metabot-enabled?": true,
});
setupEnterprisePlugins();
renderWithProviders(<MetabotUsageIndicator />, {
storeInitialState: createMockState({ settings }),
});
}
describe("MetabotUsageIndicator", () => {
it("renders nothing when no limits configured", () => {
setup({
usage: createMockMetabotUsage({
user_limit: null,
instance_limit: null,
}),
});
expect(screen.queryByLabelText("AI usage")).not.toBeInTheDocument();
});
it("renders nothing when no usage data", () => {
setup({ usage: null });
expect(screen.queryByLabelText("AI usage")).not.toBeInTheDocument();
});
it("renders ring progress when user limit is configured", async () => {
setup({
usage: createMockMetabotUsage({
user_usage: 50,
user_limit: 100,
}),
});
expect(await screen.findByLabelText("AI usage")).toBeInTheDocument();
});
it("renders ring progress when instance limit is configured", async () => {
setup({
usage: createMockMetabotUsage({
user_limit: null,
instance_usage: 200,
instance_limit: 500,
}),
});
expect(await screen.findByLabelText("AI usage")).toBeInTheDocument();
});
it("shows tooltip with usage text", async () => {
setup({
usage: createMockMetabotUsage({
user_usage: 50,
user_limit: 100,
instance_limit: null,
}),
});
expect(await screen.findByLabelText("AI usage")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
import { t } from "ttag";
import type { UsageScope } from "metabase/metabot/hooks/use-metabot-usage";
export function formatUsageText(
scope: UsageScope,
unit: string | null,
): string {
const limit = scope.limit;
const usage = Math.min(scope.usage, limit);
if (unit === "messages") {
return t`${usage} / ${limit} messages used`;
}
const tokenUsage = usage.toFixed(1);
const tokenLimit = limit.toFixed(1);
return t`${tokenUsage} / ${tokenLimit}M tokens used`;
}

View File

@@ -3,4 +3,5 @@ export { useMetabotAgent } from "./use-metabot-agent";
export { useMetabotAgentsManager } from "./use-metabot-agents-manager";
export { useMetabotName } from "./use-metabot-name";
export { useDashCardAnalysis } from "./useDashCardAnalysis";
export { useMetabotUsage } from "./use-metabot-usage";
export { useUserMetabotPermissions } from "./use-user-metabot-permissions";

View File

@@ -0,0 +1,103 @@
import { useMemo } from "react";
import { useGetUserMetabotPermissionsQuery } from "metabase/api";
import type { MetabotUsage } from "metabase-types/api";
import { useMetabotEnabledEmbeddingAware } from "./use-metabot-embedding-aware-enabled";
export type UsageScope = {
usage: number;
limit: number;
percent: number;
isNearLimit: boolean;
isAtLimit: boolean;
};
export type PoolUsageScope = UsageScope & {
scope: "tenant" | "instance";
};
export type MetabotUsageResult = {
user: UsageScope | null;
pool: PoolUsageScope | null;
mostConstrained: (UsageScope | PoolUsageScope) | null;
limitUnit: MetabotUsage["limit_unit"] | null;
resetRate: MetabotUsage["limit_reset_rate"] | null;
isLoading: boolean;
};
function computeScope(usage: number, limit: number | null): UsageScope | null {
if (limit == null) {
return null;
}
const percent =
limit > 0 ? Math.min(100, Math.round((usage / limit) * 100)) : 100;
return {
usage,
limit,
percent,
isNearLimit: percent >= 80,
isAtLimit: percent >= 100,
};
}
export function computeUsageScopes(
usage: MetabotUsage | null | undefined,
): Omit<MetabotUsageResult, "isLoading"> {
if (!usage) {
return {
user: null,
pool: null,
mostConstrained: null,
limitUnit: null,
resetRate: null,
};
}
const user = computeScope(usage.user_usage, usage.user_limit);
let pool: PoolUsageScope | null = null;
if (usage.tenant_usage != null && usage.tenant_limit != null) {
const base = computeScope(usage.tenant_usage, usage.tenant_limit);
if (base) {
pool = { ...base, scope: "tenant" };
}
} else {
const base = computeScope(usage.instance_usage, usage.instance_limit);
if (base) {
pool = { ...base, scope: "instance" };
}
}
let mostConstrained: (UsageScope | PoolUsageScope) | null = null;
if (user && pool) {
mostConstrained = user.percent >= pool.percent ? user : pool;
} else {
mostConstrained = user ?? pool;
}
return {
user,
pool,
mostConstrained,
limitUnit: usage.limit_unit,
resetRate: usage.limit_reset_rate,
};
}
export const useMetabotUsage = (): MetabotUsageResult => {
const isMetabotEnabled = useMetabotEnabledEmbeddingAware();
const { data, isLoading } = useGetUserMetabotPermissionsQuery(undefined, {
skip: !isMetabotEnabled,
});
const usage = data?.usage;
return useMemo(
() => ({
...computeUsageScopes(usage),
isLoading,
}),
[usage, isLoading],
);
};

View File

@@ -0,0 +1,176 @@
import { createMockMetabotUsage } from "metabase-types/api/mocks/metabot";
import { computeUsageScopes } from "./use-metabot-usage";
describe("computeUsageScopes", () => {
it("returns all nulls when usage is null", () => {
const result = computeUsageScopes(null);
expect(result).toEqual({
user: null,
pool: null,
mostConstrained: null,
limitUnit: null,
resetRate: null,
});
});
it("returns all nulls when usage is undefined", () => {
const result = computeUsageScopes(undefined);
expect(result).toEqual({
user: null,
pool: null,
mostConstrained: null,
limitUnit: null,
resetRate: null,
});
});
it("returns null scopes when no limits are configured", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 50,
user_limit: null,
instance_usage: 200,
instance_limit: null,
}),
);
expect(result.user).toBeNull();
expect(result.pool).toBeNull();
expect(result.mostConstrained).toBeNull();
expect(result.limitUnit).toBe("tokens");
expect(result.resetRate).toBe("monthly");
});
it("computes user scope when user limit is configured", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 50,
user_limit: 100,
instance_limit: null,
}),
);
expect(result.user).toEqual({
usage: 50,
limit: 100,
percent: 50,
isNearLimit: false,
isAtLimit: false,
});
expect(result.pool).toBeNull();
expect(result.mostConstrained).toBe(result.user);
});
it("computes pool scope from instance when no tenant", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_limit: null,
instance_usage: 300,
instance_limit: 500,
}),
);
expect(result.user).toBeNull();
expect(result.pool).toEqual({
usage: 300,
limit: 500,
percent: 60,
isNearLimit: false,
isAtLimit: false,
scope: "instance",
});
expect(result.mostConstrained).toBe(result.pool);
});
it("computes pool scope from tenant when tenant is present", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_limit: null,
instance_usage: 300,
instance_limit: 500,
tenant_usage: 80,
tenant_limit: 100,
}),
);
expect(result.pool).toEqual({
usage: 80,
limit: 100,
percent: 80,
isNearLimit: true,
isAtLimit: false,
scope: "tenant",
});
});
it("picks user as mostConstrained when user is closer to limit", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 90,
user_limit: 100,
instance_usage: 100,
instance_limit: 500,
}),
);
expect(result.mostConstrained).toBe(result.user);
});
it("picks pool as mostConstrained when pool is closer to limit", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 10,
user_limit: 100,
instance_usage: 450,
instance_limit: 500,
}),
);
expect(result.mostConstrained).toBe(result.pool);
});
it("detects near-limit at 80%", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 80,
user_limit: 100,
}),
);
expect(result.user?.isNearLimit).toBe(true);
expect(result.user?.isAtLimit).toBe(false);
});
it("detects at-limit at 100%", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 100,
user_limit: 100,
}),
);
expect(result.user?.isAtLimit).toBe(true);
});
it("caps percent at 100 when usage exceeds limit", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 150,
user_limit: 100,
}),
);
expect(result.user?.percent).toBe(100);
expect(result.user?.isAtLimit).toBe(true);
});
it("handles zero limit gracefully", () => {
const result = computeUsageScopes(
createMockMetabotUsage({
user_usage: 0,
user_limit: 0,
}),
);
expect(result.user?.percent).toBe(100);
expect(result.user?.isAtLimit).toBe(true);
});
it("returns messages as limitUnit when configured", () => {
const result = computeUsageScopes(
createMockMetabotUsage({ limit_unit: "messages" }),
);
expect(result.limitUnit).toBe("messages");
});
});

View File

@@ -3,11 +3,13 @@ import { push } from "react-router-redux";
import { P, match } from "ts-pattern";
import _ from "underscore";
import { Api } from "metabase/api";
import {
aiStreamingQuery,
findMatchingInflightAiStreamingRequests,
} from "metabase/api/ai-streaming";
import type { ProcessedChatResponse } from "metabase/api/ai-streaming/process-stream";
import { listTag } from "metabase/api/tags";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { setIsNativeEditorOpen } from "metabase/query_builder/actions";
import type { Dispatch, State } from "metabase/redux/store";
@@ -286,6 +288,8 @@ export const submitInput = createAsyncThunk<
const result = await sendMessageRequestPromise;
dispatch(Api.util.invalidateTags([listTag("metabot-permissions")]));
if (isRejected(result)) {
if (result.payload?.type === "error") {
dispatch(

View File

@@ -129,7 +129,15 @@ export const lastReqBody = async (
agentSpy: ReturnType<typeof mockAgentEndpoint>,
) => {
await waitFor(() => expect(agentSpy).toHaveBeenCalled());
return JSON.parse(agentSpy.mock.lastCall?.[1]?.body as string);
const matchingCall = agentSpy.mock.calls
.reverse()
.find(([url]) => typeof url === "string" && url.includes("/api/metabot"));
const body = matchingCall?.[1]?.body;
if (!body) {
return undefined;
}
return JSON.parse(body.toString());
};
// Common mock response fixtures

View File

@@ -0,0 +1 @@
export { RingProgress } from "@mantine/core";

View File

@@ -1,4 +1,5 @@
export * from "./Loader";
export * from "./Alert";
export * from "./Progress";
export * from "./RingProgress";
export * from "./Skeleton";

View File

@@ -4,7 +4,8 @@
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.metabot.scope :as scope]))
[metabase.metabot.scope :as scope]
[metabase.metabot.usage :as usage]))
(defn- simplify-permissions
"Strip the namespace from permission keywords for a flatter JSON response.
@@ -12,6 +13,18 @@
[perms]
(into {} (map (fn [[k v]] [(keyword (name k)) v])) perms))
(def ^:private usage-schema
[:map
[:user_usage number?]
[:user_limit [:maybe number?]]
[:instance_usage number?]
[:instance_limit [:maybe number?]]
[:tenant_usage {:optional true} [:maybe number?]]
[:tenant_limit {:optional true} [:maybe number?]]
[:limit_unit [:enum "tokens" "messages"]]
[:limit_reset_rate [:enum "daily" "weekly" "monthly"]]
[:period_start :string]])
(def ^:private user-permissions-response-schema
[:map
[:permissions
@@ -19,7 +32,8 @@
[:metabot [:enum :yes :no]]
[:metabot-sql-generation [:enum :yes :no]]
[:metabot-nlq [:enum :yes :no]]
[:metabot-other-tools [:enum :yes :no]]]]])
[:metabot-other-tools [:enum :yes :no]]]]
[:usage [:maybe usage-schema]]])
(api.macros/defendpoint :get "/user-permissions" :- user-permissions-response-schema
"Return the current user's resolved metabot permissions, taking the most
@@ -28,7 +42,8 @@
{:permissions (simplify-permissions
(if api/*is-superuser?*
scope/all-yes-permissions
(scope/resolve-user-permissions api/*current-user-id*)))})
(scope/resolve-user-permissions api/*current-user-id*)))
:usage (usage/usage-summary)})
(def ^{:arglists '([request respond raise])} routes
"`/api/metabot/permissions` routes."

View File

@@ -44,6 +44,13 @@
[]
nil)
(defenterprise usage-summary
"Return a usage summary map for the current user showing current usage and applicable limits.
Returns nil in OSS."
metabase-enterprise.metabot.usage
[]
nil)
(defn- meter-value
[meters meter-key]
(some-> meter-key keyword meters))

View File

@@ -9,13 +9,14 @@
(is (= "Unauthenticated"
(mt/client :get 401 "metabot/permissions/user-permissions"))))
(testing "superuser gets all-yes permissions"
(let [perms (:permissions (mt/user-http-request :crowberto :get 200 "metabot/permissions/user-permissions"))]
(is (= "yes" (:metabot perms)))
(is (= "yes" (:metabot-sql-generation perms)))
(is (= "yes" (:metabot-nlq perms)))
(is (= "yes" (:metabot-other-tools perms)))))
(testing "non-admin user gets resolved permissions"
(is (= {:metabot "yes"
:metabot-sql-generation "yes"
:metabot-nlq "yes"
:metabot-other-tools "yes"}
(:permissions (mt/user-http-request :crowberto :get 200 "metabot/permissions/user-permissions")))))
(testing "non-admin user gets resolved permissions with all perm types present"
(let [perms (:permissions (mt/user-http-request :rasta :get 200 "metabot/permissions/user-permissions"))]
(is (= 4 (count perms)))
(testing "all perm types are present with string values"
(is (every? string? (vals perms))))))))
(is (every? string? (vals perms)))))
(testing "response includes usage key"
(is (contains? (mt/user-http-request :rasta :get 200 "metabot/permissions/user-permissions") :usage)))))