, dirtyFields: readonly string[] = []) => {
try {
if (!accessToken) return;
@@ -334,6 +349,12 @@ export default function KeyInfoView({
formValues.tpd_limit = mapEmptyStringToNull(formValues.tpd_limit);
formValues.max_parallel_requests = mapEmptyStringToNull(formValues.max_parallel_requests);
+ const budgetAlertRowEntries = dirtyFields.includes(KEY_MAX_BUDGET_ALERT_EMAILS_KEY)
+ ? budgetAlertEmailsFromRows(formValues.max_budget_alert_emails ?? [])
+ : budgetAlertEmailsRowsCanShow(currentKeyData.metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
+ const budgetAlertEmailsWithBoxEntries = (boxMetadata: unknown) =>
+ budgetAlertEmailsMetadata(budgetAlertRowEntries, KEY_MAX_BUDGET_ALERT_EMAILS_KEY, boxMetadata);
+
// Convert metadata back to an object if it exists and is a string
if (formValues.metadata && typeof formValues.metadata === "string") {
try {
@@ -342,9 +363,12 @@ export default function KeyInfoView({
if ("tags" in parsedMetadata) {
delete parsedMetadata["tags"];
}
+ const budgetAlertEmails = budgetAlertEmailsWithBoxEntries(parsedMetadata);
+ delete parsedMetadata[KEY_MAX_BUDGET_ALERT_EMAILS_KEY];
formValues.metadata = {
...parsedMetadata,
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
+ ...budgetAlertEmails,
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0
? { logging: formValues.logging_settings }
@@ -362,10 +386,11 @@ export default function KeyInfoView({
}
} else {
const baseMetadata = formValues.metadata || {};
- const { tags: _omitTags, ...rest } = baseMetadata;
+ const { tags: _omitTags, [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _boxAlertEmails, ...rest } = baseMetadata;
formValues.metadata = {
...rest,
...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}),
+ ...budgetAlertEmailsWithBoxEntries(baseMetadata),
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0
? { logging: formValues.logging_settings }
@@ -383,6 +408,7 @@ export default function KeyInfoView({
delete formValues.tags;
}
delete formValues.logging_settings;
+ delete formValues.max_budget_alert_emails;
// Normalize any legacy word-form budget_duration to the canonical API format
if (formValues.budget_duration) {
@@ -395,14 +421,6 @@ export default function KeyInfoView({
formValues.budget_duration = wordToCanonical[formValues.budget_duration] ?? formValues.budget_duration;
}
- const memberKeyEditContext = {
- userRole: userRole || "",
- userId: userID || "",
- keyUserId: currentKeyData.user_id,
- keyTeamId: currentKeyData.team_id,
- teamMembers: teamsData?.find((team) => team.team_id === currentKeyData.team_id)?.members_with_roles,
- };
- const editingMemberKeyAsTeamAdmin = isTeamAdminEditingMemberKey(memberKeyEditContext);
if (editingMemberKeyAsTeamAdmin) {
const trimmed = teamAdminMemberKeyPayload(formValues, dirtyFields);
if (trimmed.kind === "blocked") {
@@ -1010,6 +1028,7 @@ export default function KeyInfoView({
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
+ editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin}
/>
) : (
diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json
index 6e09e3eb785..a2369c9f077 100644
--- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json
+++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json
@@ -2391,6 +2391,17 @@
"description": "Tags are managed by the Tags field above. A tags array typed here is moved to that field."
}
},
+ "maxBudgetAlertEmails": {
+ "budgetAlertThresholds": "Budget Alert Thresholds",
+ "budgetAlertThresholdsHint": "Thresholds are a percentage of the key's max budget and replace this key's default 80% alert. Global alert thresholds configured on the proxy still apply. Thresholds outside 1 to 100 can be edited in the Metadata box. The key owner's email is always included. Add comma-separated addresses, such as a shared mailbox or distribution list, to notify others as well. Requires email alerting to be configured on the proxy.",
+ "thresholdPlaceholder": "% of budget",
+ "recipientsPlaceholder": "Additional recipients, e.g. finance@example.com",
+ "removeThreshold": "Remove budget alert threshold",
+ "addThreshold": "Add Budget Alert Threshold",
+ "thresholdRange": "Enter a whole number from 1 to 100",
+ "duplicateThreshold": "Duplicate threshold",
+ "movedThresholds": "Moved alert thresholds {{thresholds}} from metadata to the Budget Alert Thresholds rows"
+ },
"estimatedOutputTokens": {
"invalidPerModel": "Enter a JSON object of positive integers, e.g. {\"gpt-4\": 4096}",
"invalidPositive": "Enter a positive integer",
@@ -20670,6 +20681,72 @@
"maxBudget": "Max budget (USD)",
"maxBudgetPlaceholder": "Leave empty for no budget",
"sharedBudgetWarning": "This customer is on budget {{budgetId}}. Changing the budget here changes it for every customer on that budget."
+ },
+ "analytics": {
+ "title": "Customer usage",
+ "customers": "Customers",
+ "breadcrumb": "Customer hierarchy",
+ "selectOrganization": "Open a customer organization to explore its usage.",
+ "backToCustomers": "Back to customers",
+ "organizationUsage": "Organization usage",
+ "teamUsage": "Team usage",
+ "memberUsage": "Member usage",
+ "subtitle": "Understand where usage goes, from your organization to each member.",
+ "trend": "Usage over time",
+ "trendHint": "Daily activity, broken down by {{dimension}}.",
+ "metric": "Usage metric",
+ "spend": "Spend",
+ "tokens": "Tokens",
+ "requests": "Requests",
+ "bucket": "Time interval",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "loading": "Loading customer usage",
+ "empty": "No usage in this range",
+ "error": "Could not load customer usage",
+ "breakdown": "Usage breakdown",
+ "models": "Models",
+ "keys": "API keys",
+ "useCases": "Use cases",
+ "teams": "Teams",
+ "members": "Members",
+ "sortedBy": "Ranked by {{metric}}",
+ "search": "Search breakdown",
+ "rank": "Rank",
+ "share": "Share",
+ "rankingHint": "Share of the selected metric across this customer scope. Click a team or member to explore its usage.",
+ "useCaseHint": "Use cases recorded with requests. Unattributed activity appears as Unassigned.",
+ "noMatches": "No results match your search.",
+ "noBreakdown": "No usage recorded for this breakdown.",
+ "pagination": "{{from}}–{{to}} of {{total}}",
+ "previous": "Previous",
+ "next": "Next",
+ "allOrganizationUsage": "View all organization usage",
+ "freshness": "Usage appears after accounting completes. Date ranges use UTC. Customer budgets are managed separately.",
+ "scopeMismatch": "This team or member does not belong to the selected organization.",
+ "invalidIdentifier": "Select a valid customer identifier.",
+ "incompletePages": "Could not load the complete usage range. Please choose fewer days.",
+ "unassignedTeam": "Unassigned or removed team",
+ "unassignedMember": "Unassigned or removed member",
+ "unnamedKey": "Unnamed key",
+ "currentSpend": "Budget spend",
+ "noBudget": "No budget set",
+ "breakDownBy": "Break down by",
+ "topModels": "Top models",
+ "topKeys": "Top API keys",
+ "topTeams": "Top teams",
+ "topMembers": "Top members",
+ "memberSpend": "Member spend",
+ "memberSpendHint": "Usage in the selected date range. Cap status uses each member’s recorded budget spend.",
+ "searchMembers": "Search members",
+ "member": "Member",
+ "currentBudget": "Spend / cap",
+ "status": "Status",
+ "noCap": "No cap",
+ "atBudget": "At budget",
+ "withinBudget": "Within budget",
+ "noMembers": "No members in this customer scope.",
+ "overBudget": "Over budget"
}
}
}
diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 2d9f7cb42ad..72a2c20824f 100644
--- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -9608,6 +9608,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/management/v1/customer_members/daily/activity": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Customer Member Daily Activity
+ * @description Get paginated daily customer member activity with model, provider, key and member breakdowns. Filters accept comma-separated member IDs. Proxy admins only.
+ */
+ get: operations["customer_member_daily_activity_management_v1_customer_members_daily_activity_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/management/v1/customer_members/{customer_member_id}": {
parameters: {
query?: never;
@@ -9660,6 +9680,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/management/v1/customer_organizations/daily/activity": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Customer Organization Daily Activity
+ * @description Get paginated daily customer organization activity with model, provider, key and organization breakdowns. Filters accept comma-separated organization IDs. Proxy admins only.
+ */
+ get: operations["customer_organization_daily_activity_management_v1_customer_organizations_daily_activity_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/management/v1/customer_organizations/{customer_org_id}": {
parameters: {
query?: never;
@@ -9756,6 +9796,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/management/v1/customer_teams/daily/activity": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Customer Team Daily Activity
+ * @description Get paginated daily customer team activity with model, provider, key and team breakdowns. Filters accept comma-separated team IDs. Proxy admins only.
+ */
+ get: operations["customer_team_daily_activity_management_v1_customer_teams_daily_activity_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/management/v1/customer_teams/{customer_team_id}": {
parameters: {
query?: never;
@@ -68150,6 +68210,44 @@ export interface operations {
};
};
};
+ customer_member_daily_activity_management_v1_customer_members_daily_activity_get: {
+ parameters: {
+ query?: {
+ customer_member_ids?: string | null;
+ start_date?: string | null;
+ end_date?: string | null;
+ model?: string | null;
+ api_key?: string | null;
+ page?: number;
+ page_size?: number;
+ exclude_customer_member_ids?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_customer_member_management_v1_customer_members__customer_member_id__get: {
parameters: {
query?: never;
@@ -68310,6 +68408,44 @@ export interface operations {
};
};
};
+ customer_organization_daily_activity_management_v1_customer_organizations_daily_activity_get: {
+ parameters: {
+ query?: {
+ customer_org_ids?: string | null;
+ start_date?: string | null;
+ end_date?: string | null;
+ model?: string | null;
+ api_key?: string | null;
+ page?: number;
+ page_size?: number;
+ exclude_customer_org_ids?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_customer_organization_management_v1_customer_organizations__customer_org_id__get: {
parameters: {
query?: never;
@@ -68572,6 +68708,44 @@ export interface operations {
};
};
};
+ customer_team_daily_activity_management_v1_customer_teams_daily_activity_get: {
+ parameters: {
+ query?: {
+ customer_team_ids?: string | null;
+ start_date?: string | null;
+ end_date?: string | null;
+ model?: string | null;
+ api_key?: string | null;
+ page?: number;
+ page_size?: number;
+ exclude_customer_team_ids?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_customer_team_management_v1_customer_teams__customer_team_id__get: {
parameters: {
query?: never;
diff --git a/litellm/utils.py b/litellm/utils.py
index 67b7b4f3d1e..118f89766a9 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -5024,9 +5024,7 @@ def get_optional_params_embeddings(
optional_params["dimensions"] = non_default_params.pop("dimensions")
if len(non_default_params.keys()) > 0:
if litellm.drop_params is True or drop_params is True: # drop the unsupported non-default values
- keys = list(non_default_params.keys())
- for k in keys:
- non_default_params.pop(k, None)
+ non_default_params.clear()
else:
raise UnsupportedParamsError(
status_code=500,
@@ -5039,9 +5037,7 @@ def get_optional_params_embeddings(
):
if len(non_default_params.keys()) > 0:
if litellm.drop_params is True or drop_params is True: # drop the unsupported non-default values
- keys = list(non_default_params.keys())
- for k in keys:
- non_default_params.pop(k, None)
+ non_default_params.clear()
else:
raise UnsupportedParamsError(
status_code=500,
@@ -5060,7 +5056,7 @@ def get_optional_params_embeddings(
additional_drop_params=kwargs.get("additional_drop_params", None),
)
- if "extra_body" in final_params and len(final_params["extra_body"]) == 0:
+ if "extra_body" in final_params and not final_params["extra_body"]:
final_params.pop("extra_body", None)
return final_params
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 4429fffe652..7c8a48f5aae 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -28650,7 +28650,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
- "successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
+ "successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@@ -29124,7 +29124,7 @@
"mode": "chat",
"output_cost_per_token": 2.8e-07,
"source": "https://docs.fireworks.ai/serverless/pricing",
- "successor_model": "fireworks_ai/deepseek-v4-flash-0731",
+ "successor_model": "fireworks_ai/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@@ -83697,7 +83697,7 @@
"mode": "chat",
"output_cost_per_token": 2.1e-07,
"source": "https://api.fireworks.ai/v1/serverless/models",
- "successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731",
+ "successor_model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
diff --git a/pyproject.toml b/pyproject.toml
index ccfbd5d7d9b..3b7e3e154b9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm"
-version = "1.110.0"
+version = "1.111.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.16"
@@ -88,7 +88,7 @@ proxy = [
"httpx2>=2.10.0,<3",
"pydantic>=2.12.0,<3",
"litellm-proxy-extras==0.4.115",
- "litellm-enterprise==0.1.83",
+ "litellm-enterprise==0.1.84",
"RestrictedPython>=8.5,<9.0",
"rich>=13.9.4,<14.0",
"InquirerPy>=0.3.4,<1.0",
@@ -441,7 +441,7 @@ litellm-enterprise = { workspace = true }
members = ["litellm/enterprise", "litellm/proxy-extras", "litellm/packaging/litellm-proxy"]
[tool.commitizen]
-version = "1.110.0"
+version = "1.111.0"
version_files = [
"pyproject.toml:^version",
]
diff --git a/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts b/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts
new file mode 100644
index 00000000000..af9c0942331
--- /dev/null
+++ b/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts
@@ -0,0 +1,164 @@
+import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
+import {
+ ADMIN_STORAGE_PATH,
+ E2E_TEAM_CRUD_ALIAS,
+ E2E_TEAM_CRUD_ID,
+} from "../../constants";
+import { Page } from "../../fixtures/pages";
+import {
+ dismissFeedbackPopup,
+ navigateToPage,
+ openKeyDetail,
+} from "../../helpers/navigation";
+import { readBack } from "../../helpers/roundTrip";
+import {
+ createVirtualKey,
+ deleteVirtualKey,
+ uniqueSuffix,
+} from "../../helpers/traffic";
+
+const SHARED_MAILBOX = "shared-mailbox@example.com";
+const DISTRIBUTION_LIST = "finance-dl@example.com";
+const RECIPIENTS_PLACEHOLDER =
+ "Additional recipients, e.g. finance@example.com";
+
+async function keyMetadata(
+ page: PlaywrightPage,
+ token: string,
+): Promise> {
+ const body = await readBack<{ info: { metadata: Record } }>(
+ page,
+ `/key/info?key=${encodeURIComponent(token)}`,
+ );
+ return body.info.metadata;
+}
+
+async function tokenForAlias(
+ page: PlaywrightPage,
+ alias: string,
+): Promise {
+ const body = await readBack<{ keys: { key_alias: string; token: string }[] }>(
+ page,
+ `/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
+ );
+ return body.keys.find((row) => row.key_alias === alias)?.token;
+}
+
+async function addAlertRow(
+ page: PlaywrightPage,
+ threshold: string,
+ emails: string,
+): Promise {
+ await page
+ .getByRole("button", { name: "Add Budget Alert Threshold" })
+ .click();
+ await page.getByPlaceholder("% of budget").last().fill(threshold);
+ await page.getByPlaceholder(RECIPIENTS_PLACEHOLDER).last().fill(emails);
+}
+
+test.describe("Proxy Admin - Key budget alert recipients", () => {
+ test.use({ storageState: ADMIN_STORAGE_PATH });
+
+ test("Create Key stores the alert recipients next to the metadata JSON", async ({
+ page,
+ }) => {
+ const alias = `e2e-alert-create-${uniqueSuffix()}`;
+ await navigateToPage(page, Page.ApiKeys);
+ await dismissFeedbackPopup(page);
+ await page.getByRole("button", { name: /Create New Key/i }).click();
+ await page.getByLabel(/Key Name/).fill(alias);
+ await page.getByTestId("team-dropdown").getByRole("combobox").click();
+ await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
+ await page
+ .getByRole("option", { name: E2E_TEAM_CRUD_ALIAS })
+ .first()
+ .click();
+ await page.getByRole("combobox", { name: "Select models" }).click();
+ await page
+ .getByRole("option", { name: "All Team Models", exact: true })
+ .click();
+ await page.keyboard.press("Escape");
+
+ await page.getByRole("button", { name: /Optional Settings/i }).click();
+ await page.getByLabel(/Max Budget \(USD\)/).fill("10");
+ await addAlertRow(page, "80", `${SHARED_MAILBOX}, ${DISTRIBUTION_LIST}`);
+ await page.getByLabel("Metadata").fill('{"cost_center": "ops"}');
+ await page.getByRole("button", { name: "Create Key", exact: true }).click();
+ await expect(page.getByText("Save your Key")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ const token = await tokenForAlias(page, alias);
+ expect(token, `key ${alias} readable from /key/list`).toBeTruthy();
+ try {
+ expect(await keyMetadata(page, token!)).toMatchObject({
+ cost_center: "ops",
+ max_budget_alert_emails: { "80": [SHARED_MAILBOX, DISTRIBUTION_LIST] },
+ });
+ } finally {
+ await deleteVirtualKey(page.request, token!);
+ }
+ });
+
+ test("Edit Key round-trips the rows, keeps the rest of the metadata, and clears them when every row is removed", async ({
+ page,
+ }) => {
+ const alias = `e2e-alert-edit-${uniqueSuffix()}`;
+ const created = await createVirtualKey(page.request, {
+ key_alias: alias,
+ team_id: E2E_TEAM_CRUD_ID,
+ max_budget: 10,
+ metadata: {
+ cost_center: "ops",
+ max_budget_alert_emails: { "80": [SHARED_MAILBOX] },
+ },
+ });
+ try {
+ await navigateToPage(page, Page.ApiKeys);
+ await dismissFeedbackPopup(page);
+ await openKeyDetail(page, alias);
+ await page.getByRole("tab", { name: "Settings" }).click();
+ await page.getByRole("button", { name: "Edit Settings" }).click();
+
+ await expect(page.getByPlaceholder("% of budget")).toHaveValue("80");
+ await expect(page.getByPlaceholder(RECIPIENTS_PLACEHOLDER)).toHaveValue(
+ SHARED_MAILBOX,
+ );
+ expect(
+ JSON.parse(await page.getByLabel("Metadata").inputValue()),
+ ).toEqual({ cost_center: "ops" });
+
+ await page
+ .getByRole("button", { name: "Remove budget alert threshold" })
+ .click();
+ await addAlertRow(page, "50", DISTRIBUTION_LIST);
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await expect
+ .poll(() => keyMetadata(page, created.token), { timeout: 20_000 })
+ .toMatchObject({
+ cost_center: "ops",
+ max_budget_alert_emails: { "50": [DISTRIBUTION_LIST] },
+ });
+
+ await page.getByRole("button", { name: "Edit Settings" }).click();
+ await expect(page.getByPlaceholder("% of budget")).toHaveValue("50");
+ await page
+ .getByRole("button", { name: "Remove budget alert threshold" })
+ .click();
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await expect
+ .poll(
+ async () =>
+ "max_budget_alert_emails" in
+ (await keyMetadata(page, created.token)),
+ { timeout: 20_000 },
+ )
+ .toBe(false);
+ expect(await keyMetadata(page, created.token)).toMatchObject({
+ cost_center: "ops",
+ });
+ } finally {
+ await deleteVirtualKey(page.request, created.token);
+ }
+ });
+});
diff --git a/tests/integration/database/image/test_image_admin_mcp.py b/tests/integration/database/image/test_image_admin_mcp.py
index aab22f354e4..a7fd33f1417 100644
--- a/tests/integration/database/image/test_image_admin_mcp.py
+++ b/tests/integration/database/image/test_image_admin_mcp.py
@@ -186,7 +186,7 @@ def test_image_admin_mcp_personal_admin_manages_team(offline_postgres: tuple[str
"--env", "HOME=/opt/prisma", "--env", "XDG_CACHE_HOME=/opt/prisma/.cache",
"--env", "PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries",
"--entrypoint", "prisma", SCHEMA_IMAGE,
- "db", "push", "--schema", "/app/schema.prisma", "--skip-generate", "--accept-data-loss",
+ "db", "push", "--schema", "/app/litellm/schema.prisma", "--skip-generate", "--accept-data-loss",
],
capture_output=True, text=True, timeout=180, check=False,
)
diff --git a/tests/integration/spend/test_key_budget_alerts.py b/tests/integration/spend/test_key_budget_alerts.py
new file mode 100644
index 00000000000..a904d2ba03a
--- /dev/null
+++ b/tests/integration/spend/test_key_budget_alerts.py
@@ -0,0 +1,74 @@
+import uuid
+from pathlib import Path
+from typing import Final
+
+import pytest
+import yaml
+from integration._support.client import Gateway, eventually
+from integration._support.database import read_rows
+from integration._support.mail import smtp_sink
+from integration._support.process import owned_proxy
+
+pytestmark: Final = pytest.mark.shares_worker_state
+
+KEY_BUDGET: Final = 0.10
+CALL_COST: Final = 20 * 0.001 + 20 * 0.002
+
+
+def _key_spend(key: str) -> float:
+ rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE key_alias = %s', (key,))
+ return float(str(rows[0]["spend"])) if rows else 0.0
+
+
+def test_key_threshold_emails_owner_and_shared_mailbox_from_admin_ui_metadata(gateway: Gateway, tmp_path: Path) -> None:
+ owner_email: Final = f"owner-{uuid.uuid4().hex}@integration.test"
+ shared_mailbox: Final = f"shared-mailbox-{uuid.uuid4().hex}@integration.test"
+ alias: Final = f"alert-key-{uuid.uuid4().hex}"
+ configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
+ configuration["general_settings"]["alerting"] = ["email"]
+ configuration["general_settings"]["user_api_key_cache_ttl"] = 1
+ path: Final = tmp_path / "email-alerting.yaml"
+ path.write_text(yaml.safe_dump(configuration))
+ with smtp_sink() as mailbox:
+ overrides: Final = {
+ "SMTP_HOST": mailbox.host,
+ "SMTP_PORT": str(mailbox.port),
+ "SMTP_TLS": "False",
+ "SMTP_SENDER_EMAIL": "alerts@integration.test",
+ }
+ with owned_proxy(gateway, tmp_path, overrides, config=path) as candidate, candidate.scenario() as scenario:
+ model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
+ user_id: Final = scenario.user(user_email=owner_email)
+ key: Final = scenario.key(
+ key_alias=alias,
+ user_id=user_id,
+ models=[model],
+ max_budget=KEY_BUDGET,
+ metadata={"cost_center": "ops", "max_budget_alert_emails": {"50": [shared_mailbox]}},
+ )
+
+ first: Final = candidate.request(
+ "POST",
+ "/v1/chat/completions",
+ {"model": model, "messages": [{"role": "user", "content": "first call"}]},
+ key=key,
+ )
+ assert first.status_code == 200, first.text
+ assert float(first.headers["x-litellm-response-cost"]) == pytest.approx(CALL_COST)
+ eventually(lambda: _key_spend(alias), lambda spend: spend == pytest.approx(CALL_COST), seconds=70)
+ assert mailbox.deliveries() == (), "no threshold is reached before the first call is recorded"
+
+ second: Final = candidate.request(
+ "POST",
+ "/v1/chat/completions",
+ {"model": model, "messages": [{"role": "user", "content": "second call"}]},
+ key=key,
+ )
+ assert second.status_code == 200, second.text
+ halfway: Final = eventually(mailbox.deliveries, lambda found: len(found) >= 2, seconds=30)
+ assert {recipient for delivery in halfway for recipient in delivery.recipients} == {
+ owner_email,
+ shared_mailbox,
+ }, halfway
+ assert all("50%" in delivery.subject for delivery in halfway), halfway
+ assert len(halfway) == 2, halfway
diff --git a/tests/unit/accounting/test_application.py b/tests/unit/accounting/test_application.py
new file mode 100644
index 00000000000..d60893a147d
--- /dev/null
+++ b/tests/unit/accounting/test_application.py
@@ -0,0 +1,103 @@
+from collections.abc import Mapping
+from dataclasses import dataclass, replace
+from datetime import datetime, timezone
+from typing import Final, cast
+
+import pytest
+from pydantic import TypeAdapter
+
+from litellm.accounting.application import _apply_customer_usage
+from litellm.accounting.delivery_projection import project_deliveries
+from litellm.accounting.projection import Contribution
+from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
+from litellm.repositories.accounting_delivery_repository import DeliveryProjection, LedgerTransaction
+
+_FIELDS: Final = TypeAdapter(Mapping[str, object])
+_TEXT: Final = TypeAdapter(str)
+_UPDATES: Final = TypeAdapter(Mapping[str, Mapping[str, float]])
+_IDENTIFIER: Final = "\U0001f680" * 256
+_ATTRIBUTION: Final = CustomerUsageAttribution(
+ customer_org_id=_IDENTIFIER, customer_team_id=_IDENTIFIER, customer_member_id=_IDENTIFIER
+)
+
+
+@dataclass(frozen=True, slots=True)
+class Bucket:
+ values: Mapping[str, object]
+ counters: Mapping[str, float]
+
+
+class Buckets:
+ def __init__(self) -> None:
+ self.rows: Mapping[str, Bucket] = {}
+
+ async def upsert(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> None:
+ row_id: Final = _TEXT.validate_python(where["id"])
+ create: Final = _FIELDS.validate_python(data["create"])
+ previous: Final = self.rows.get(row_id)
+ increments: Final = (
+ {name: fields["increment"] for name, fields in _UPDATES.validate_python(data["update"]).items()}
+ if previous is not None
+ else {name: float(value) for name, value in create.items() if isinstance(value, (int, float))}
+ )
+ self.rows = {
+ **self.rows,
+ row_id: Bucket(
+ values=previous.values if previous is not None else create,
+ counters={
+ name: (previous.counters.get(name, 0) if previous is not None else 0) + value
+ for name, value in increments.items()
+ },
+ ),
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class Transaction:
+ litellm_dailycustomermemberspend: Buckets
+
+
+def contribution(alias: str, provider: str, spend: float) -> Contribution:
+ delivery: Final = DeliveryProjection(
+ {
+ "request_id": "long-customer-id-request",
+ "startTime": datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat(),
+ "api_key": "hashed-key",
+ "model": "shared-model",
+ "model_group": alias,
+ "custom_llm_provider": provider,
+ "spend": spend,
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "metadata": {CUSTOMER_USAGE_METADATA_KEY: _ATTRIBUTION.model_dump_json()},
+ }
+ )
+ return next(item for item in project_deliveries((delivery,)) if item.table == "LiteLLM_DailyCustomerMemberSpend")
+
+
+@pytest.mark.asyncio
+async def test_long_customer_ids_roll_up_without_truncation_and_keep_alias_provider_buckets_distinct() -> None:
+ buckets: Final = Buckets()
+ transaction: Final = cast(LedgerTransaction, Transaction(buckets)) # cast-ok: in-memory Prisma upsert boundary
+ first: Final = contribution("alias-a", "provider-a", 1)
+ second: Final = replace(contribution("alias-a", "provider-a", 2), key=tuple(reversed(first.key)))
+
+ await _apply_customer_usage(transaction, (first,))
+ await _apply_customer_usage(transaction, (second, contribution("alias-b", "provider-a", 4)))
+ await _apply_customer_usage(transaction, (contribution("alias-a", "provider-b", 8),))
+
+ assert len(buckets.rows) == 3
+ assert all(len(row_id.encode("utf-8")) == 64 for row_id in buckets.rows)
+ assert all(
+ row.values["customer_org_id"]
+ == row.values["customer_team_id"]
+ == row.values["customer_member_id"]
+ == _IDENTIFIER
+ for row in buckets.rows.values()
+ )
+ assert {
+ (row.values["model_group"], row.values["custom_llm_provider"]): row.counters["spend"]
+ for row in buckets.rows.values()
+ } == {("alias-a", "provider-a"): 3, ("alias-b", "provider-a"): 4, ("alias-a", "provider-b"): 8}
+ assert sum(row.counters["api_requests"] for row in buckets.rows.values()) == 4
+ assert sum(row.counters["prompt_tokens"] + row.counters["completion_tokens"] for row in buckets.rows.values()) == 60
diff --git a/tests/unit/accounting/test_projection.py b/tests/unit/accounting/test_projection.py
index edf0fc4110d..62e4938f489 100644
--- a/tests/unit/accounting/test_projection.py
+++ b/tests/unit/accounting/test_projection.py
@@ -6,20 +6,120 @@ from typing import Final
import pytest
from pydantic import TypeAdapter
+from litellm.accounting.delivery_projection import project_deliveries
from litellm.accounting.projection import project
from litellm.accounting.snapshot import build_accounting_payload
from litellm.constants import BILLED_RETRY_ATTEMPT_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.proxy._types import SpendLogsPayload
+from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.proxy_server import proxy_logging_obj
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.utils import PrismaClient
+from litellm.repositories.accounting_delivery_repository import DeliveryProjection
from litellm.types.utils import ModelResponse, Usage
_NOW: Final = datetime(2026, 11, 2, tzinfo=timezone.utc)
_FIELDS: Final = TypeAdapter(dict[str, object])
+def test_customer_model_aliases_and_providers_keep_separate_daily_totals() -> None:
+ stamp: Final = CustomerUsageAttribution(customer_org_id="org-1", customer_member_id="member-1").model_dump_json()
+ base: Final = {
+ "startTime": _NOW,
+ "api_key": "hashed-key",
+ "model": "shared-model",
+ "metadata": {CUSTOMER_USAGE_METADATA_KEY: stamp},
+ }
+ deliveries: Final = tuple(
+ DeliveryProjection({**base, "model_group": alias, "custom_llm_provider": provider, "spend": spend})
+ for alias, provider, spend in (
+ ("alias-a", "provider-a", 1),
+ ("alias-b", "provider-a", 2),
+ ("alias-a", "provider-b", 3),
+ )
+ )
+ totals: Final = {
+ (dict(item.key)["model_group"], dict(item.key)["custom_llm_provider"]): dict(item.increments)["spend"]
+ for item in project_deliveries(deliveries)
+ if item.table == "LiteLLM_DailyCustomerMemberSpend"
+ }
+ assert totals == {("alias-a", "provider-a"): 1, ("alias-b", "provider-a"): 2, ("alias-a", "provider-b"): 3}
+
+
+@pytest.mark.parametrize("retry", [False, True])
+def test_customer_usage_survives_logging_and_accounting_with_correct_request_counts(retry: bool) -> None:
+ attribution: Final = CustomerUsageAttribution(
+ customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"
+ )
+ payload: Final = get_logging_payload(
+ kwargs={
+ "model": "usage-model",
+ "custom_llm_provider": "openai",
+ "call_type": "acompletion",
+ "litellm_call_id": "usage-request",
+ "litellm_params": {
+ "metadata": {
+ "user_api_key": "hashed-key",
+ "status": "success",
+ CUSTOMER_USAGE_METADATA_KEY: attribution.model_dump_json(),
+ **({BILLED_RETRY_ATTEMPT_METADATA_KEY: True} if retry else {}),
+ }
+ },
+ },
+ response_obj=ModelResponse(
+ id="usage-request", model="usage-model", usage=Usage(prompt_tokens=10, completion_tokens=5)
+ ),
+ start_time=_NOW,
+ end_time=_NOW,
+ )
+ accounting: Final = build_accounting_payload(
+ {**payload, "spend": 0.75}, entities={}, model_access_groups=(), windows=(), router=lambda: None
+ )
+ contributions: Final = project(accounting)
+ usage: Final = next(item for item in contributions if item.table == "LiteLLM_DailyCustomerMemberSpend")
+ assert dict(usage.key) == {
+ "date": _NOW.date().isoformat(),
+ "customer_org_id": attribution.customer_org_id,
+ "customer_team_id": attribution.customer_team_id,
+ "customer_member_id": attribution.customer_member_id,
+ "model": payload["model"],
+ "model_group": payload["model_group"],
+ "custom_llm_provider": payload["custom_llm_provider"],
+ "api_key": payload["api_key"],
+ "mcp_namespaced_tool_name": "",
+ "endpoint": "/chat/completions",
+ }
+ assert dict(usage.increments) == {
+ "spend": 0.75,
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "api_requests": 0 if retry else 1,
+ "successful_requests": 0 if retry else 1,
+ "failed_requests": 0,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0,
+ "compression_saved_tokens": 0,
+ "compression_savings_spend": 0,
+ "prompt_caching_savings_spend": 0,
+ "gateway_injected_caching_savings_spend": 0,
+ "autorouter_savings_spend": 0,
+ "guardrail_spend": 0,
+ "total_response_time_ms": 0,
+ "timed_requests": 1,
+ }
+ assert {
+ item.table: dict(item.increments)["spend"]
+ for item in contributions
+ if item.table
+ in ("LiteLLM_CustomerOrganizationTable", "LiteLLM_CustomerTeamTable", "LiteLLM_CustomerMemberTable")
+ } == {
+ "LiteLLM_CustomerOrganizationTable": 0.75,
+ "LiteLLM_CustomerTeamTable": 0.75,
+ "LiteLLM_CustomerMemberTable": 0.75,
+ }
+
+
@pytest.mark.parametrize("status", ["success", "failure"])
@pytest.mark.parametrize("origin", [None, "auto_router", "billed_retry"])
@pytest.mark.parametrize("spend", [0.0, 0.25])
diff --git a/tests/unit/accounting/test_worker.py b/tests/unit/accounting/test_worker.py
index d3b5d71cff9..372ae90b158 100644
--- a/tests/unit/accounting/test_worker.py
+++ b/tests/unit/accounting/test_worker.py
@@ -1,5 +1,9 @@
import json
+from collections.abc import AsyncIterator, Mapping
+from contextlib import asynccontextmanager
from dataclasses import dataclass
+from datetime import datetime, timezone
+from functools import partial
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock
@@ -10,11 +14,18 @@ from fakeredis.aioredis import FakeRedis
from pydantic import TypeAdapter
from litellm import in_memory_llm_clients_cache
+from litellm.accounting.application import apply_deliveries
from litellm.accounting.worker import flush_accounting_deliveries
from litellm.caching.redis_cache import RedisCache
+from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY, CustomerUsageAttribution
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.utils import PrismaClient, ProxyLogging
+from litellm.repositories.accounting_delivery_repository import (
+ AccountingDeliveryRepository,
+ DeliveryProjection,
+ LedgerTransaction,
+)
_FIELDS: Final = TypeAdapter(dict[str, object])
_INCREMENTS: Final = TypeAdapter(dict[str, dict[str, float]])
@@ -30,7 +41,7 @@ class _Ledger:
spend: float = 0.0
attempts: int = 0
- async def query(self, query: str, *args: object) -> tuple[dict[str, object], ...]:
+ async def query(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
if query.startswith("SELECT delivery_id, attempts"):
if not self.pending or self.attempts or args[1] is True:
return ()
@@ -96,3 +107,119 @@ async def test_delivery_charges_the_creating_key_despite_an_orphaned_maintenance
in_memory_llm_clients_cache.delete_cache(key=cache_key)
await redis.aclose()
sync_redis.close()
+
+
+class _CustomerLedger:
+ def __init__(self, fail_acknowledgement: bool) -> None:
+ self.pending = True
+ self.attempts = 0
+ self.fail_acknowledgement = fail_acknowledgement
+ self.daily: Mapping[str, int | float] = {}
+ self.entities: Mapping[str, float] = {}
+
+ async def query(self, query: str, *args: object) -> tuple[Mapping[str, object], ...]:
+ if query.startswith("SELECT delivery_id, attempts"):
+ if not self.pending or (self.attempts > 0) is not args[1]:
+ return ()
+ return (
+ {
+ "delivery_id": "customer-delivery",
+ "attempts": self.attempts,
+ "accounting_payload": _CUSTOMER_PAYLOAD,
+ },
+ )
+ if "SET attempts = attempts + 1" in query:
+ self.attempts += 1
+ return ({"delivery_id": "customer-delivery", "attempts": self.attempts},)
+ return ()
+
+ async def execute(self, query: str, *args: object) -> int:
+ if "SET state = 'applied'" not in query:
+ return 0
+ if self.fail_acknowledgement:
+ self.fail_acknowledgement = False
+ raise OSError("database connection lost before delivery acknowledgement")
+ self.pending = False
+ return 1
+
+ def upsert_daily(self, *, where: object, data: object) -> None:
+ values: Final = _FIELDS.validate_python(_FIELDS.validate_python(data)["create"])
+ counters: Final = {name: value for name, value in values.items() if isinstance(value, (int, float))}
+ self.daily = {name: self.daily.get(name, 0) + value for name, value in counters.items()}
+
+ def update_entity(self, entity: str, *, where: object, data: object) -> int:
+ spend: Final = _INCREMENTS.validate_python(data)["spend"]["increment"]
+ self.entities = {**self.entities, entity: self.entities.get(entity, 0.0) + spend}
+ return 1
+
+
+_CUSTOMER_PAYLOAD: Final = json.dumps(
+ {
+ "request_id": "customer-completed-request",
+ "startTime": datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat(),
+ "api_key": "hashed-key",
+ "spend": 0.75,
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "metadata": {
+ CUSTOMER_USAGE_METADATA_KEY: CustomerUsageAttribution(
+ customer_org_id="customer-org", customer_team_id="customer-team", customer_member_id="customer-member"
+ ).model_dump_json()
+ },
+ }
+)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("fail_acknowledgement", (False, True))
+async def test_customer_daily_and_lifetime_totals_commit_once_after_delivery_replay(
+ fail_acknowledgement: bool,
+) -> None:
+ ledger: Final = _CustomerLedger(fail_acknowledgement)
+ transaction: Final = MagicMock()
+ transaction.query_raw = AsyncMock(side_effect=ledger.query)
+ transaction.execute_raw = AsyncMock(side_effect=ledger.execute)
+ transaction.litellm_dailycustomermemberspend.upsert = AsyncMock(side_effect=ledger.upsert_daily)
+ transaction.litellm_customerorganizationtable.update_many = AsyncMock(
+ side_effect=partial(ledger.update_entity, "organization")
+ )
+ transaction.litellm_customerteamtable.update_many = AsyncMock(side_effect=partial(ledger.update_entity, "team"))
+ transaction.litellm_customermembertable.update_many = AsyncMock(side_effect=partial(ledger.update_entity, "member"))
+ transaction.batch_.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
+ transaction.batch_.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ @asynccontextmanager
+ async def database_transaction(*, timeout: object) -> AsyncIterator[LedgerTransaction]:
+ before: Final = (ledger.pending, ledger.daily, ledger.entities)
+ try:
+ yield transaction
+ except Exception:
+ ledger.pending, ledger.daily, ledger.entities = before
+ raise
+
+ database: Final = MagicMock(spec=PrismaClient)
+ database.tx.side_effect = database_transaction
+ repository: Final = AccountingDeliveryRepository(database)
+ writer: Final = DBSpendUpdateWriter(accounting_clock=lambda: 0.0)
+ logger: Final = ProxyLogging(user_api_key_cache=UserApiKeyCache())
+
+ async def apply(tx: LedgerTransaction, deliveries: tuple[DeliveryProjection, ...]) -> None:
+ await apply_deliveries(tx, deliveries, prisma_client=database, writer=writer, proxy_logging_obj=logger)
+
+ first: Final = await repository.apply_pending(apply)
+ if fail_acknowledgement:
+ assert first.failed == 1
+ assert ledger.daily == {}
+ assert ledger.entities == {}
+ retried: Final = await repository.apply_pending(apply)
+ assert retried.applied == 1
+ else:
+ assert first.applied == 1
+ await repository.apply_pending(apply)
+ await repository.apply_pending(apply)
+ assert ledger.daily["spend"] == 0.75
+ assert ledger.daily["prompt_tokens"] == 10
+ assert ledger.daily["completion_tokens"] == 5
+ assert ledger.daily["api_requests"] == 1
+ assert ledger.entities == {"organization": 0.75, "team": 0.75, "member": 0.75}
+ assert ledger.pending is False
diff --git a/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json b/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json
index 6e5461c3b60..40674758371 100644
--- a/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json
+++ b/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json
@@ -11,7 +11,7 @@
"user": "",
"team_id": "",
"organization_id": "",
- "metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"billed_retry_attempt\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null, \"kubernetes_pod_routing\": null}",
+ "metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"billed_retry_attempt\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_customer_usage\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null, \"kubernetes_pod_routing\": null}",
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,
diff --git a/tests/unit/integrations/langfuse/langfuse_expected_request_body/completion_with_bedrock_call.json b/tests/unit/integrations/langfuse/langfuse_expected_request_body/completion_with_bedrock_call.json
index 6f359380245..96548edc60f 100644
--- a/tests/unit/integrations/langfuse/langfuse_expected_request_body/completion_with_bedrock_call.json
+++ b/tests/unit/integrations/langfuse/langfuse_expected_request_body/completion_with_bedrock_call.json
@@ -2,9 +2,6 @@
"name": "litellm-acompletion",
"parent_span_id": null,
"attributes": {
- "langfuse.observation.cost_details": {
- "total": 6e-05
- },
"langfuse.observation.input": {
"messages": [
{
diff --git a/tests/unit/integrations/langfuse/test_langfuse.py b/tests/unit/integrations/langfuse/test_langfuse.py
index 7a374c1affe..e7fcad5c748 100644
--- a/tests/unit/integrations/langfuse/test_langfuse.py
+++ b/tests/unit/integrations/langfuse/test_langfuse.py
@@ -97,7 +97,9 @@ def _comparable(span: Mapping[str, object]) -> dict[str, object]:
}
-def _expected_generation(route: respx.Route, fixture_name: str, trace_id: str) -> dict[str, object]:
+def _expected_generation(
+ route: respx.Route, fixture_name: str, trace_id: str, cost_total: float | None = None
+) -> dict[str, object]:
otel_trace_id: Final = resolve_trace_id(trace_id)
generations: Final = tuple(
span
@@ -108,7 +110,14 @@ def _expected_generation(route: respx.Route, fixture_name: str, trace_id: str) -
)
assert len(generations) == 1
expected_path: Final = Path(__file__).with_name("langfuse_expected_request_body") / fixture_name
- expected: Final = TypeAdapter(dict[str, object]).validate_python(json.loads(expected_path.read_text()))
+ fixture: Final = TypeAdapter(dict[str, object]).validate_python(json.loads(expected_path.read_text()))
+ fixture_attributes: Final = TypeAdapter(dict[str, object]).validate_python(fixture["attributes"])
+ priced_attributes: Final = (
+ fixture_attributes
+ if cost_total is None
+ else {**fixture_attributes, "langfuse.observation.cost_details": {"total": cost_total}}
+ )
+ expected: Final = {**fixture, "attributes": priced_attributes}
actual: Final = _comparable(generations[0])
expected_comparable: Final = _comparable(expected)
assert actual == expected_comparable, json.dumps(
@@ -355,6 +364,12 @@ async def test_langfuse_logging_completion_with_bedrock_llm_response(
langfuse_export: tuple[respx.Route, str],
) -> None:
route, trace_id = langfuse_export
+ profile: Final = litellm.get_model_info(
+ model="us.anthropic.claude-haiku-4-5-20251001-v1:0", custom_llm_provider="bedrock"
+ )
+ input_rate: Final = profile["input_cost_per_token"]
+ output_rate: Final = profile["output_cost_per_token"]
+ assert isinstance(input_rate, float) and isinstance(output_rate, float)
response: Final = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20),
@@ -373,7 +388,9 @@ async def test_langfuse_logging_completion_with_bedrock_llm_response(
)
await _flush_langfuse()
assert route.call_count == 1
- _expected_generation(route, "completion_with_bedrock_call.json", trace_id)
+ _expected_generation(
+ route, "completion_with_bedrock_call.json", trace_id, cost_total=10 * input_rate + 10 * output_rate
+ )
@pytest.mark.asyncio
diff --git a/tests/unit/llms/base_llm/embedding/test_provider_embedding_translation.py b/tests/unit/llms/base_llm/embedding/test_provider_embedding_translation.py
index 24157263daa..316fdad5493 100644
--- a/tests/unit/llms/base_llm/embedding/test_provider_embedding_translation.py
+++ b/tests/unit/llms/base_llm/embedding/test_provider_embedding_translation.py
@@ -106,3 +106,4 @@ def test_image_embedding(case: _Case, respx_mock: MockRouter) -> None:
else:
assert body["inputImage"] == png_b64
assert response.data[0]["embedding"] == [0.11, 0.22, 0.33]
+
diff --git a/tests/unit/llms/bedrock/embed/test_bedrock_embedding.py b/tests/unit/llms/bedrock/embed/test_bedrock_embedding.py
index 904ca3bb8ca..63526e917a6 100644
--- a/tests/unit/llms/bedrock/embed/test_bedrock_embedding.py
+++ b/tests/unit/llms/bedrock/embed/test_bedrock_embedding.py
@@ -190,6 +190,34 @@ def test_bedrock_cohere_embedding_returns_the_scripted_vector(respx_mock: respx.
assert response.data == [{"object": "embedding", "index": 0, "embedding": [0.3, 0.2, 0.1]}]
+def test_bedrock_cohere_embedding_sends_max_tokens_and_priority(respx_mock: respx.MockRouter) -> None:
+ route = respx_mock.post("https://bedrock-runtime.us-east-1.amazonaws.com/model/cohere.embed-v4:0/invoke").mock(
+ return_value=httpx.Response(
+ 200,
+ json={"embeddings": {"float": [[0.3, 0.2, 0.1]]}, "response_type": "embeddings_by_type"},
+ )
+ )
+
+ litellm.embedding(
+ model="bedrock/cohere.embed-v4:0",
+ input=["hello"],
+ api_key="test-bearer-token",
+ aws_region_name="us-east-1",
+ aws_bedrock_runtime_endpoint="https://bedrock-runtime.us-east-1.amazonaws.com",
+ max_tokens=16,
+ priority=5,
+ truncate="END",
+ )
+
+ assert json.loads(route.calls.last.request.read()) == {
+ "input_type": "search_document",
+ "texts": ["hello"],
+ "max_tokens": 16,
+ "priority": 5,
+ "truncate": "END",
+ }
+
+
GOVCLOUD_WEST_RUNTIME: Final = "https://bedrock-runtime.us-gov-west-1.amazonaws.com"
@@ -2130,3 +2158,122 @@ def test_bedrock_embedding_region_bug_reproduction():
img_base_64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkBAMAAACCzIhnAAAAG1BMVEURAAD///+ln5/h39/Dv79qX18uHx+If39MPz9oMSdmAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABB0lEQVRYhe2SzWrEIBCAh2A0jxEs4j6GLDS9hqWmV5Flt0cJS+lRwv742DXpEjY1kOZW6HwHFZnPmVEBEARBEARB/jd0KYA/bcUYbPrRLh6amXHJ/K+ypMoyUaGthILzw0l+xI0jsO7ZcmCcm4ILd+QuVYgpHOmDmz6jBeJImdcUCmeBqQpuqRIbVmQsLCrAalrGpfoEqEogqbLTWuXCPCo+Ki1XGqgQ+jVVuhB8bOaHkvmYuzm/b0KYLWwoK58oFqi6XfxQ4Uz7d6WeKpna6ytUs5e8betMcqAv5YPC5EZB2Lm9FIn0/VP6R58+/GEY1X1egVoZ/3bt/EqF6malgSAIgiDIH+QL41409QMY0LMAAAAASUVORK5CYII="
+
+
+@pytest.mark.parametrize(
+ ("model_id", "embed_response", "extra_body", "expected_body"),
+ [
+ (
+ "amazon.titan-embed-text-v2:0",
+ titan_embedding_response,
+ {"normalize": False, "dimensions": 256},
+ {"inputText": test_input, "normalize": False, "dimensions": 256},
+ ),
+ (
+ "cohere.embed-english-v3",
+ cohere_embedding_response,
+ {"truncate": "END", "embedding_types": ["float"]},
+ {"texts": [test_input], "truncate": "END", "embedding_types": ["float"]},
+ ),
+ ],
+)
+def test_bedrock_embedding_sends_extra_body_fields_in_the_request_body(
+ respx_mock: respx.MockRouter,
+ model_id: str,
+ embed_response: dict[str, object],
+ extra_body: dict[str, object],
+ expected_body: dict[str, object],
+) -> None:
+ route = respx_mock.post(f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/invoke").mock(
+ return_value=httpx.Response(200, json=embed_response)
+ )
+
+ litellm.embedding(
+ model=f"bedrock/{model_id}",
+ input=[test_input],
+ api_key="test-bearer-token",
+ aws_region_name="us-east-1",
+ extra_body=extra_body,
+ )
+
+ body = json.loads(route.calls.last.request.read())
+ assert "extra_body" not in body
+ assert expected_body.items() <= body.items()
+
+
+def test_bedrock_nova_embedding_sends_extra_body_fields_in_the_request_body(respx_mock: respx.MockRouter) -> None:
+ model_id: Final = "amazon.nova-2-multimodal-embeddings-v1:0"
+ route = respx_mock.post(f"https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/invoke").mock(
+ return_value=httpx.Response(200, json={"embeddings": [{"embeddingType": "TEXT", "embedding": [0.1, 0.2]}]})
+ )
+
+ litellm.embedding(
+ model=f"bedrock/{model_id}",
+ input=[test_input],
+ api_key="test-bearer-token",
+ aws_region_name="us-east-1",
+ extra_body={"segmentationConfig": {"durationSeconds": 10}},
+ )
+
+ body = json.loads(route.calls.last.request.read())
+ assert "extra_body" not in body
+ assert body["segmentationConfig"] == {"durationSeconds": 10}
+
+
+@pytest.mark.parametrize(
+ ("extra_body", "field", "expected"),
+ [
+ ({"dimensions": 512}, "dimensions", 256),
+ ({"inputText": "text the guardrails never saw"}, "inputText", test_input),
+ ],
+ ids=["mapped_param", "input"],
+)
+def test_bedrock_embedding_extra_body_cannot_replace_the_input_or_a_mapped_param(
+ respx_mock: respx.MockRouter, extra_body: dict[str, object], field: str, expected: object
+) -> None:
+ route = respx_mock.post(
+ "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v2:0/invoke"
+ ).mock(return_value=httpx.Response(200, json=titan_embedding_response))
+
+ litellm.embedding(
+ model="bedrock/amazon.titan-embed-text-v2:0",
+ input=[test_input],
+ api_key="test-bearer-token",
+ aws_region_name="us-east-1",
+ dimensions=256,
+ extra_body=extra_body,
+ )
+
+ assert json.loads(route.calls.last.request.read())[field] == expected
+
+
+def test_bedrock_embedding_signs_the_body_it_sends(respx_mock: respx.MockRouter) -> None:
+ from botocore.auth import SigV4Auth
+ from botocore.awsrequest import AWSRequest
+ from botocore.credentials import Credentials
+
+ route = respx_mock.post(
+ "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v2:0/invoke"
+ ).mock(return_value=httpx.Response(200, json=titan_embedding_response))
+
+ litellm.embedding(
+ model="bedrock/amazon.titan-embed-text-v2:0",
+ input=[test_input],
+ aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
+ aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ aws_region_name="us-east-1",
+ dimensions=256,
+ extra_body={"dimensions": 512},
+ )
+
+ sent: Final = route.calls.last.request
+ replay: Final = AWSRequest(
+ method="POST", url=str(sent.url), data=sent.content, headers={"Content-Type": "application/json"}
+ )
+ replay.context["timestamp"] = sent.headers["x-amz-date"]
+ SigV4Auth(
+ Credentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), "bedrock", "us-east-1"
+ ).add_auth(replay)
+
+ assert json.loads(sent.content)["dimensions"] == 256
+ assert replay.headers["Authorization"] == sent.headers["authorization"]
diff --git a/tests/unit/llms/nvidia_nim/test_nvidia_nim.py b/tests/unit/llms/nvidia_nim/test_nvidia_nim.py
index 3633ac97265..04bd26d53ef 100644
--- a/tests/unit/llms/nvidia_nim/test_nvidia_nim.py
+++ b/tests/unit/llms/nvidia_nim/test_nvidia_nim.py
@@ -237,3 +237,28 @@ def test_completion_nvidia_nim():
assert request_body["model"] == "databricks/dbrx-instruct"
assert request_body["frequency_penalty"] == 0.1
assert request_body["presence_penalty"] == 0.5
+
+
+def test_embedding_nvidia_nim_sends_extra_body_fields_without_an_extra_body_key():
+ from openai import OpenAI
+
+ transport: Final = CapturingTransport(
+ CreateEmbeddingResponse(
+ object="list",
+ data=(Embedding(object="embedding", index=0, embedding=(0.1, 0.2, 0.3)),),
+ model="nvidia/nv-embedqa-e5-v5",
+ usage=EmbeddingUsage(prompt_tokens=6, total_tokens=6),
+ )
+ )
+ client: Final = OpenAI(api_key="fake-api-key", http_client=httpx.Client(transport=transport))
+
+ litellm.embedding(
+ model="nvidia_nim/nvidia/nv-embedqa-e5-v5",
+ input="What is the meaning of life?",
+ extra_body={"input_type": "query", "truncate": "END"},
+ client=client,
+ )
+
+ request_body: Final = transport.request_bodies[0]
+ assert "extra_body" not in request_body
+ assert (request_body["input_type"], request_body["truncate"]) == ("query", "END")
diff --git a/tests/unit/llms/ollama/test_ollama_embedding.py b/tests/unit/llms/ollama/test_ollama_embedding.py
index b32bb9c4415..abbe449bf4a 100644
--- a/tests/unit/llms/ollama/test_ollama_embedding.py
+++ b/tests/unit/llms/ollama/test_ollama_embedding.py
@@ -348,3 +348,21 @@ async def test_sdk_embedding_rejects_non_string_batch(embedding_route: respx.Rou
litellm.embedding(model="ollama/nomic-embed-text", input=[123], api_base="http://ollama.test")
assert not embedding_route.called
+
+
+def test_ollama_embeddings_sends_extra_body_fields_at_the_documented_places(embedding_route: respx.Route) -> None:
+ response: Final = litellm.embedding(
+ model="ollama/nomic-embed-text",
+ input=["hello", "world"],
+ api_base="http://ollama.test",
+ extra_body={"truncate": False, "keep_alive": "5m", "options": {"num_ctx": 2048}},
+ )
+
+ assert json.loads(embedding_route.calls.last.request.content) == {
+ "model": "nomic-embed-text",
+ "input": ["hello", "world"],
+ "truncate": False,
+ "keep_alive": "5m",
+ "options": {"num_ctx": 2048},
+ }
+ assert response.data[0]["embedding"] == [0.1, 0.2]
diff --git a/tests/unit/llms/sagemaker/test_sagemaker_embedding_voyage.py b/tests/unit/llms/sagemaker/test_sagemaker_embedding_voyage.py
index 3951b17db92..3d8c5ccfa21 100644
--- a/tests/unit/llms/sagemaker/test_sagemaker_embedding_voyage.py
+++ b/tests/unit/llms/sagemaker/test_sagemaker_embedding_voyage.py
@@ -7,6 +7,7 @@ transformation, and model type detection.
"""
import json
+from typing import Final
from unittest.mock import MagicMock, patch
import httpx
@@ -105,6 +106,21 @@ class TestSagemakerCohereEmbeddingConfig:
)
assert result["texts"] == ["hello"]
+ def test_transform_request_keeps_max_tokens_and_priority(self):
+ result: Final = self.config.transform_embedding_request(
+ model="cohere-embed-v4",
+ input=["hello"],
+ optional_params={"max_tokens": 16, "priority": 5, "truncate": "END"},
+ headers={},
+ )
+ assert result == {
+ "texts": ["hello"],
+ "input_type": "search_document",
+ "max_tokens": 16,
+ "priority": 5,
+ "truncate": "END",
+ }
+
def test_map_openai_params_dimensions_to_output_dimension(self):
params = self.config.map_openai_params(
non_default_params={"dimensions": 512, "encoding_format": "float"},
@@ -626,3 +642,40 @@ class TestTEIEmbeddingResponse:
if __name__ == "__main__":
pytest.main([__file__])
+
+
+def test_voyage_endpoint_sends_extra_body_fields_in_the_invoke_body():
+ client = MagicMock()
+ client.invoke_endpoint.return_value = {
+ "Body": MagicMock(
+ read=MagicMock(
+ return_value=json.dumps(
+ {
+ "data": [{"object": "embedding", "embedding": [0.1, 0.2], "index": 0}],
+ "usage": {"total_tokens": 3},
+ }
+ ).encode()
+ )
+ )
+ }
+ session = MagicMock()
+ session.client.return_value = client
+
+ with patch("boto3.Session", return_value=session):
+ response = embedding(
+ model="sagemaker/voyage-3-5-endpoint",
+ input=["hello"],
+ aws_region_name="us-east-1",
+ aws_access_key_id="test-access-key",
+ aws_secret_access_key="test-secret-key",
+ extra_body={"input_type": "query", "truncation": False, "output_dimension": 512},
+ )
+
+ assert json.loads(client.invoke_endpoint.call_args.kwargs["Body"]) == {
+ "input": ["hello"],
+ "model": "voyage-3-5-endpoint",
+ "input_type": "query",
+ "truncation": False,
+ "output_dimension": 512,
+ }
+ assert response.data[0]["embedding"] == [0.1, 0.2]
diff --git a/tests/unit/llms/vertex_ai/gemini_embeddings/test_batch_embed_content_transformation.py b/tests/unit/llms/vertex_ai/gemini_embeddings/test_batch_embed_content_transformation.py
index d7d921ab35c..06306981daf 100644
--- a/tests/unit/llms/vertex_ai/gemini_embeddings/test_batch_embed_content_transformation.py
+++ b/tests/unit/llms/vertex_ai/gemini_embeddings/test_batch_embed_content_transformation.py
@@ -652,3 +652,32 @@ def test_gemini_embedding(respx_mock: respx.MockRouter) -> None:
expected_prompt_tokens: Final = litellm.token_counter(model="gemini/gemini-embedding-001", text="Hello, world!")
assert response.usage.prompt_tokens == expected_prompt_tokens
assert response.usage.total_tokens == expected_prompt_tokens
+
+
+def test_gemini_embedding_sends_extra_body_fields_on_each_request(respx_mock: respx.MockRouter) -> None:
+ route: Final = respx_mock.post(
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents"
+ ).mock(return_value=httpx.Response(200, json={"embeddings": [{"values": [0.1, 0.2]}]}))
+
+ litellm.embedding(
+ model="gemini/gemini-embedding-001",
+ input="Hello, world!",
+ api_key="gemini-test-key",
+ extra_body={
+ "title": "Greeting",
+ "taskType": "RETRIEVAL_DOCUMENT",
+ "outputDimensionality": 256,
+ "documentOcr": True,
+ },
+ )
+
+ assert json.loads(route.calls.last.request.content)["requests"] == [
+ {
+ "model": "models/gemini-embedding-001",
+ "content": {"parts": [{"text": "Hello, world!"}]},
+ "title": "Greeting",
+ "taskType": "RETRIEVAL_DOCUMENT",
+ "outputDimensionality": 256,
+ "documentOcr": True,
+ }
+ ]
diff --git a/tests/unit/llms/vertex_ai/multimodal_embeddings/test_vertex_ai_multimodal_embedding_transformation.py b/tests/unit/llms/vertex_ai/multimodal_embeddings/test_vertex_ai_multimodal_embedding_transformation.py
index edb6e889814..123d4128679 100644
--- a/tests/unit/llms/vertex_ai/multimodal_embeddings/test_vertex_ai_multimodal_embedding_transformation.py
+++ b/tests/unit/llms/vertex_ai/multimodal_embeddings/test_vertex_ai_multimodal_embedding_transformation.py
@@ -1,8 +1,13 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
+import google.auth
+import google.auth.credentials
import httpx
import pytest
+import respx
+
+import litellm
from litellm.llms.vertex_ai.multimodal_embeddings.transformation import (
@@ -174,3 +179,30 @@ class TestVertexMultimodalEmbedding:
headers={},
)
assert "parameters" not in request
+
+
+class _StaticGoogleCredentials(google.auth.credentials.Credentials):
+ def refresh(self, request: object) -> None:
+ self.token = "test-token"
+
+
+def test_extra_body_fields_reach_the_multimodal_predict_body(
+ respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(google.auth, "default", lambda scopes: (_StaticGoogleCredentials(), "test-project"))
+ route = respx_mock.post(host="us-central1-aiplatform.googleapis.com").mock(
+ return_value=httpx.Response(200, json={"predictions": [{"textEmbedding": [0.1, 0.2]}]})
+ )
+
+ litellm.embedding(
+ model="vertex_ai/multimodalembedding@001",
+ input="Hello world",
+ vertex_project="test-project",
+ vertex_location="us-central1",
+ extra_body={"dimension": 128},
+ )
+
+ assert json.loads(route.calls.last.request.content) == {
+ "instances": [{"text": "Hello world"}],
+ "parameters": {"dimension": 128},
+ }
diff --git a/tests/unit/llms/vertex_ai/vertex_embeddings/test_embedding_handler.py b/tests/unit/llms/vertex_ai/vertex_embeddings/test_embedding_handler.py
index 1058d4fb82d..0e8d49b01e5 100644
--- a/tests/unit/llms/vertex_ai/vertex_embeddings/test_embedding_handler.py
+++ b/tests/unit/llms/vertex_ai/vertex_embeddings/test_embedding_handler.py
@@ -305,3 +305,20 @@ async def test_vertex_embedding_usage_carries_the_billable_character_count_googl
8,
character_count,
)
+
+
+def test_vertex_embedding_call_sends_extra_body_fields_as_predict_parameters(predict_endpoint: respx.Route) -> None:
+ predict_endpoint.mock(return_value=_predict_response({"predictions": [_gecko_prediction([0.1, 2], 3)]}))
+
+ litellm.embedding(
+ model=f"vertex_ai/{GECKO}",
+ input=["hello"],
+ vertex_project=PUBLIC_CALL_PROJECT,
+ vertex_location="us-central1",
+ extra_body={"autoTruncate": False},
+ )
+
+ body: Final = json.loads(predict_endpoint.calls.last.request.content)
+ assert body["instances"] == [{"content": "hello"}]
+ assert "extra_body" not in body["parameters"]
+ assert body["parameters"]["autoTruncate"] is False
diff --git a/tests/unit/proxy/common_utils/test_customer_usage.py b/tests/unit/proxy/common_utils/test_customer_usage.py
new file mode 100644
index 00000000000..79b16cafa82
--- /dev/null
+++ b/tests/unit/proxy/common_utils/test_customer_usage.py
@@ -0,0 +1,225 @@
+from collections.abc import Mapping
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Final, cast # noqa: TID251 # the fake exposes only the Prisma lookup methods exercised here
+
+import pytest
+
+from litellm.proxy.common_utils.customer_usage import (
+ CustomerUsageAttribution,
+ CustomerUsageDatabase,
+ CustomerUsageError,
+ CustomerUsageFailure,
+ resolve_customer_usage,
+)
+
+
+@dataclass(frozen=True, slots=True)
+class Table:
+ identifier: str
+ rows: Mapping[str, Mapping[str, object]]
+
+ async def find_unique(self, where: Mapping[str, object]) -> Mapping[str, object] | None:
+ return self.rows.get(str(where[self.identifier]))
+
+
+@dataclass(frozen=True, slots=True)
+class Database:
+ litellm_customerorganizationtable: Table
+ litellm_customerteamtable: Table
+ litellm_customermembertable: Table
+
+
+DATABASE: Final = cast( # cast-ok: the external Prisma fake implements the lookup subset used by attribution
+ CustomerUsageDatabase,
+ Database(
+ Table("customer_org_id", {"org-1": {}, "org-2": {}}),
+ Table("customer_team_id", {"team-1": {"parent_org_id": "org-1"}, "team-2": {"parent_org_id": "org-2"}}),
+ Table("customer_member_id", {"member-1": {"parent_team_id": "team-1"}}),
+ ),
+)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("metadata", "expected"),
+ (
+ (
+ {"customer_member_id": "member-1"},
+ CustomerUsageAttribution(customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"),
+ ),
+ ({"customer_team_id": "team-1"}, CustomerUsageAttribution(customer_org_id="org-1", customer_team_id="team-1")),
+ ({"customer_org_id": "org-1"}, CustomerUsageAttribution(customer_org_id="org-1")),
+ ),
+)
+async def test_resolves_ancestors_without_requiring_redundant_request_ids(
+ metadata: Mapping[str, object], expected: CustomerUsageAttribution
+) -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata=metadata, key_metadata={}, administrator=True, database=DATABASE, ledger_enabled=True
+ )
+ assert actual == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("metadata", "key_metadata", "administrator", "expected"),
+ (
+ ({"customer_member_id": "member-1", "customer_team_id": "team-2"}, {}, True, CustomerUsageError.MISMATCH),
+ ({"customer_member_id": "member-1"}, {"customer_org_id": "org-2"}, False, CustomerUsageError.FORBIDDEN),
+ ({"customer_team_id": "team-2", "customer_org_id": "org-1"}, {}, True, CustomerUsageError.MISMATCH),
+ ({"customer_org_id": "org-2"}, {"customer_org_id": "org-1"}, False, CustomerUsageError.FORBIDDEN),
+ ({"customer_org_id": "org-1"}, {}, False, CustomerUsageError.UNBOUND),
+ ({"customer_member_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
+ ({"customer_team_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
+ ({"customer_org_id": "missing"}, {}, True, CustomerUsageError.NOT_FOUND),
+ ({"customer_org_id": 123}, {}, True, CustomerUsageError.INVALID),
+ ),
+)
+async def test_rejects_cross_organization_attribution_and_invalid_identifiers(
+ metadata: Mapping[str, object],
+ key_metadata: Mapping[str, object],
+ administrator: bool,
+ expected: CustomerUsageError,
+) -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata=metadata,
+ key_metadata=key_metadata,
+ administrator=administrator,
+ database=DATABASE,
+ ledger_enabled=True,
+ )
+ assert actual == CustomerUsageFailure(expected)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("metadata", "key_metadata"),
+ (
+ ({"customer_member_id": "missing"}, {"customer_org_id": "org-1"}),
+ ({"customer_member_id": "member-1"}, {"customer_org_id": "org-2"}),
+ (
+ {"customer_member_id": "member-1"},
+ {"customer_org_id": "org-2", "customer_team_id": "team-2"},
+ ),
+ ({"customer_team_id": "missing"}, {"customer_org_id": "org-1"}),
+ ({"customer_team_id": "team-2"}, {"customer_org_id": "org-1"}),
+ ({}, {"customer_org_id": "missing"}),
+ ),
+)
+async def test_non_admin_cannot_distinguish_missing_and_other_organization_identifiers(
+ metadata: Mapping[str, object], key_metadata: Mapping[str, object]
+) -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata=metadata,
+ key_metadata=key_metadata,
+ administrator=False,
+ database=DATABASE,
+ ledger_enabled=True,
+ )
+ assert actual == CustomerUsageFailure(CustomerUsageError.FORBIDDEN)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("administrator", (False, True))
+async def test_invalid_identifier_types_keep_validation_errors(administrator: bool) -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata={"customer_member_id": 123},
+ key_metadata={"customer_org_id": "org-1"},
+ administrator=administrator,
+ database=DATABASE,
+ ledger_enabled=True,
+ )
+ assert actual == CustomerUsageFailure(CustomerUsageError.INVALID)
+
+
+@pytest.mark.asyncio
+async def test_key_binding_tracks_requests_with_no_repeated_customer_metadata() -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata={},
+ key_metadata={"customer_org_id": "org-1"},
+ administrator=False,
+ database=DATABASE,
+ ledger_enabled=True,
+ )
+ assert actual == CustomerUsageAttribution(customer_org_id="org-1")
+
+
+@pytest.mark.asyncio
+async def test_requires_accounting_before_accepting_a_tracked_request() -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata={"customer_org_id": "org-1"},
+ key_metadata={},
+ administrator=True,
+ database=DATABASE,
+ ledger_enabled=False,
+ )
+ assert actual == CustomerUsageFailure(CustomerUsageError.UNAVAILABLE)
+
+
+@pytest.mark.asyncio
+async def test_untracked_requests_do_not_require_a_database_or_ledger() -> None:
+ actual: Final = await resolve_customer_usage(
+ metadata={}, key_metadata={}, administrator=False, database=None, ledger_enabled=False
+ )
+ assert actual is None
+
+
+@pytest.mark.asyncio
+async def test_untracked_request_enrichment_does_not_touch_customer_database_tables(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from fastapi import Request
+
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
+ from litellm.proxy.proxy_server import ProxyConfig
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=object()))
+ request: Final = Request(
+ {"type": "http", "path": "/v1/chat/completions", "method": "POST", "headers": (), "query_string": b""}
+ )
+ enriched: Final = await add_litellm_data_to_request(
+ data={"model": "usage-model", "metadata": {"application": "support"}},
+ request=request,
+ user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
+ proxy_config=ProxyConfig(),
+ general_settings={},
+ )
+ assert enriched["metadata"]["application"] == "support"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ["/v1/chat/completions", "/v1/responses"])
+async def test_request_enrichment_replaces_forged_stamps_and_preserves_valid_attribution(
+ monkeypatch: pytest.MonkeyPatch, path: str
+) -> None:
+ from fastapi import Request
+ from pydantic import TypeAdapter
+
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.common_utils.customer_usage import CUSTOMER_USAGE_METADATA_KEY
+ from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request, get_metadata_variable_name
+ from litellm.proxy.proxy_server import ProxyConfig
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=DATABASE))
+ request: Final = Request({"type": "http", "path": path, "method": "POST", "headers": (), "query_string": b""})
+ metadata_name: Final = get_metadata_variable_name(request)
+ forged: Final = CustomerUsageAttribution(customer_org_id="org-2").model_dump_json()
+ metadata: Final = {"customer_member_id": "member-1", CUSTOMER_USAGE_METADATA_KEY: forged}
+ enriched: Final = await add_litellm_data_to_request(
+ data={
+ "model": "usage-model",
+ "metadata": {CUSTOMER_USAGE_METADATA_KEY: forged},
+ "litellm_metadata": {CUSTOMER_USAGE_METADATA_KEY: forged},
+ metadata_name: metadata,
+ },
+ request=request,
+ user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", metadata={"customer_org_id": "org-1"}),
+ proxy_config=ProxyConfig(),
+ general_settings={},
+ )
+ stamp: Final = TypeAdapter(str).validate_python(enriched[metadata_name][CUSTOMER_USAGE_METADATA_KEY])
+ assert CustomerUsageAttribution.model_validate_json(stamp) == CustomerUsageAttribution(
+ customer_org_id="org-1", customer_team_id="team-1", customer_member_id="member-1"
+ )
diff --git a/tests/unit/proxy/hooks/test_dynamic_rate_limiter_v3.py b/tests/unit/proxy/hooks/test_dynamic_rate_limiter_v3.py
index 3932fae0737..93026c3ea17 100644
--- a/tests/unit/proxy/hooks/test_dynamic_rate_limiter_v3.py
+++ b/tests/unit/proxy/hooks/test_dynamic_rate_limiter_v3.py
@@ -3172,6 +3172,62 @@ def _held_to_borrow_cap(exc: ProxyRateLimitError, rate_limit_type: str, limit_se
), exc.detail
+async def _cap_tokens_remaining(handler: _PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth) -> int:
+ share: Final = handler._borrowing_team_share("shared-model", auth)
+ assert share is not None and share.ceiling is not None
+ response: Final = await handler.v3_limiter.should_rate_limit(
+ [share.ceiling], read_only=True, skip_parallel_check=True
+ )
+ return next(s["limit_remaining"] for s in response["statuses"] if s["rate_limit_type"] == "tokens")
+
+
+@pytest.mark.asyncio
+async def test_a_capped_borrowers_idle_usage_counts_toward_its_cap_but_not_toward_its_share(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ handler = _borrowing_handler(monkeypatch, borrows=True)
+ await _check(handler, _capped_team_key(2), saturation=0.0)
+ await _settle_tokens(handler, 150)
+ assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 50
+ assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 100
+ await _check(handler, _capped_team_key(2), saturation=0.5)
+ await _settle_tokens(handler, 30)
+ assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 20
+ assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 70
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("saturation", [0.0, 0.5], ids=["idle", "saturated"])
+async def test_a_capped_borrowers_reservation_is_sized_by_its_share_not_its_cap(
+ monkeypatch: pytest.MonkeyPatch, saturation: float
+):
+ """A prompt without max_tokens reserves a floor that scales with the limit it is sized against. Sizing it
+ against the cap would refuse a request its own share would let through, and would admit different
+ requests depending on whether the model happened to be saturated."""
+ handler: Final = _borrowing_handler(monkeypatch, borrows=True)
+ await _admit_at_the_hook(handler, _capped_team_key(2), tokens=160)
+ short_prompt_without_max_tokens: Final[dict[str, object]] = {"messages": [{"role": "user", "content": "hi"}]}
+ await _check(handler, _capped_team_key(2), saturation=saturation, data=short_prompt_without_max_tokens)
+ assert short_prompt_without_max_tokens["max_tokens"] == handler.v3_limiter.no_max_tokens_output_floor(100)
+
+
+@pytest.mark.asyncio
+async def test_a_capped_borrower_is_held_to_its_cap_while_idle_after_a_saturated_stretch(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ handler = _borrowing_handler(monkeypatch, borrows=True)
+ await _check(handler, _capped_team_key(2), saturation=0.0)
+ await _settle_tokens(handler, 150)
+ await _check(handler, _capped_team_key(2), saturation=0.5)
+ await _settle_tokens(handler, 60)
+ with pytest.raises(ProxyRateLimitError) as exc:
+ await _check(handler, _capped_team_key(2), saturation=0.49)
+ _held_to_borrow_cap(exc.value, "tokens", "model_tpm_limit")
+ with pytest.raises(ProxyRateLimitError) as saturated:
+ await _check(handler, _capped_team_key(2), saturation=0.5)
+ _held_to_borrow_cap(saturated.value, "tokens", "model_tpm_limit")
+
+
@pytest.mark.asyncio
async def test_team_borrows_past_its_share_while_it_stays_under_its_max_borrow_multiple(
monkeypatch: pytest.MonkeyPatch,
@@ -3280,6 +3336,44 @@ async def test_a_websocket_turn_is_refused_at_the_teams_max_borrow_multiple_whil
_held_to_borrow_cap(exc.value, "tokens", "model_tpm_limit")
+@pytest.mark.asyncio
+async def test_a_websocket_admitted_while_saturated_gives_its_share_hold_back_when_the_model_goes_idle(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ handler: Final = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
+ await _check(handler, _capped_team_key(2), saturation=0.5, data=_prompt(20))
+ assert await _team_tokens_remaining(handler, _capped_team_key(2)) < 100
+ await handler.enforce_token_limits_for_frame(user_api_key_dict=_capped_team_key(2), requested_model="shared-model")
+ await _settle_tokens(
+ handler,
+ 90,
+ response=[],
+ combined_usage_object=litellm.Usage(prompt_tokens=60, completion_tokens=30, total_tokens=90),
+ )
+ assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 10
+ assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 110
+
+
+@pytest.mark.asyncio
+async def test_a_capped_websocket_admitted_while_idle_settles_its_first_saturated_turn_on_a_fresh_share(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """The idle admission held tokens on the cap only, so the share the turn settles to once the model saturates
+ starts from zero rather than being refunded a hold it never carried."""
+ handler: Final = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
+ await _check(handler, _capped_team_key(2), saturation=0.0, data=_prompt(20))
+ await asyncio.create_task(_saturate_model_from_another_request(handler, 200))
+ await handler.enforce_token_limits_for_frame(user_api_key_dict=_capped_team_key(2), requested_model="shared-model")
+ await _settle_tokens(
+ handler,
+ 90,
+ response=[],
+ combined_usage_object=litellm.Usage(prompt_tokens=60, completion_tokens=30, total_tokens=90),
+ )
+ assert await _team_tokens_remaining(handler, _capped_team_key(2)) == 10
+ assert await _cap_tokens_remaining(handler, _capped_team_key(2)) == 110
+
+
@pytest.mark.asyncio
async def test_a_websocket_turn_under_the_teams_max_borrow_multiple_still_borrows(monkeypatch: pytest.MonkeyPatch):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.5)
@@ -3476,6 +3570,22 @@ async def _admit_at_the_hook(handler: _PROXY_DynamicRateLimitHandlerV3, auth: Us
await _settle_tokens(handler, tokens)
+async def _saturate_model_from_another_request(handler: _PROXY_DynamicRateLimitHandlerV3, tokens: int) -> None:
+ """``_saturate_model`` on a request stash of its own, as a second request in the proxy would have, so the
+ stash of the request under test keeps its holds."""
+ from litellm.proxy.hooks import parallel_request_limiter_v3
+
+ parallel_request_limiter_v3._request_stash.set(None) # pyright: ignore[reportPrivateUsage] # fresh request context
+ await _saturate_model(handler, tokens)
+
+
+async def _saturate_model(handler: _PROXY_DynamicRateLimitHandlerV3, tokens: int) -> None:
+ """Idle usage that pushes the model past its saturation threshold without touching any team's share, from a
+ priority class of its own so the default pool the measured team sits in stays untouched."""
+ warm_up: Final = UserAPIKeyAuth(api_key="sk-warm-up", team_id="team-warm-up", team_metadata={"priority": "batch"})
+ await _admit_at_the_hook(handler, warm_up, tokens=tokens)
+
+
async def _refused_at_the_hook(
handler: _PROXY_DynamicRateLimitHandlerV3, auth: UserAPIKeyAuth, model: str = "shared-model"
) -> ProxyRateLimitError:
@@ -3520,7 +3630,7 @@ async def test_a_failed_request_settles_the_team_share_gauges_too(monkeypatch: p
registry = _prometheus_sink(monkeypatch)
await _admit_then_settle(handler, _team_key(), tokens=90)
claim_request_stash_for_data({"litellm_call_id": "failed-call"})
- await _check(handler, _team_key(), saturation=0.0, data=_prompt(words=40), priority=None)
+ await _check(handler, _team_key(), saturation=0.5)
await handler.async_log_failure_event(
kwargs={
"litellm_params": {"metadata": {"model_group": "shared-model"}},
@@ -3536,10 +3646,10 @@ async def test_a_failed_request_settles_the_team_share_gauges_too(monkeypatch: p
@pytest.mark.asyncio
@pytest.mark.parametrize(
- "auth, burns, saturation_threshold, reason",
+ "auth, burns, saturation_threshold, reason, enforced_share_tokens",
[
- (_team_key(), (60, 70), 0.1, "model_per_team_saturated"),
- (_capped_team_key(2), (150, 60), 0.5, "model_per_team_max_borrow_multiple"),
+ (_team_key(), (60, 70), 0.0, "model_per_team_saturated", 100),
+ (_capped_team_key(2), (150, 60), 0.5, "model_per_team_max_borrow_multiple", 100),
],
ids=["saturated", "borrow_cap"],
)
@@ -3549,6 +3659,7 @@ async def test_a_refused_request_is_counted_under_the_reason_its_429_header_carr
burns: tuple[int, ...],
saturation_threshold: float,
reason: str,
+ enforced_share_tokens: int,
):
handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=saturation_threshold)
registry = _prometheus_sink(monkeypatch)
@@ -3564,7 +3675,11 @@ async def test_a_refused_request_is_counted_under_the_reason_its_429_header_carr
)
== 1
)
- assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS) == sum(burns) - 100
+ assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **TEAM_LABELS) == enforced_share_tokens
+ assert (
+ _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS)
+ == sum(burns) - enforced_share_tokens
+ )
@pytest.mark.asyncio
@@ -3628,13 +3743,14 @@ async def test_an_alternates_refusal_is_not_counted_while_the_preferred_model_ad
@pytest.mark.asyncio
async def test_share_gauges_and_throttles_carry_the_teams_priority_so_the_series_join(monkeypatch: pytest.MonkeyPatch):
- handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
+ handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
registry = _prometheus_sink(monkeypatch)
team: Final = UserAPIKeyAuth(
api_key="sk-interactive",
team_id="team-a",
team_metadata={"model_tpm_limit": {"shared-model": 100}, "priority": "interactive"},
)
+ await _saturate_model(handler, 200)
await _admit_at_the_hook(handler, team, tokens=60)
await _admit_at_the_hook(handler, team, tokens=70)
labels: Final = {"model_group": "shared-model", "team": "team-a", "priority": "interactive"}
@@ -3651,9 +3767,10 @@ async def test_share_gauges_and_throttles_carry_the_teams_priority_so_the_series
async def test_a_fallback_served_request_publishes_the_teams_share_on_the_fallback_model(
monkeypatch: pytest.MonkeyPatch,
):
- handler = _borrowing_handler(monkeypatch, borrows=True)
+ handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
registry = _prometheus_sink(monkeypatch)
team: Final = _team_key(team_limits={"model_tpm_limit": {"other-model": 40, "shared-model": 100}})
+ await _saturate_model(handler, 500)
assert await handler.async_pre_call_hook(team, DualCache(), {"model": "other-model"}, "completion") is None
assert await handler.async_fallback_target_allowed(team, "shared-model", {}) is True
await _settle_tokens(handler, 150, model_group="shared-model")
@@ -3663,6 +3780,35 @@ async def test_a_fallback_served_request_publishes_the_teams_share_on_the_fallba
assert _sample(registry, "litellm_dynamic_limiter_share_tokens", model_group="other-model") is None
+@pytest.mark.asyncio
+async def test_a_capped_idle_teams_borrowing_is_read_against_its_share_not_the_wider_class_share(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ handler = _borrowing_handler(monkeypatch, borrows=True, splits=True)
+ registry = _prometheus_sink(monkeypatch)
+ await _admit_at_the_hook(handler, _capped_team_key(2), tokens=150)
+ assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **TEAM_LABELS) == 100
+ assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **TEAM_LABELS) == 50
+
+
+@pytest.mark.asyncio
+async def test_an_idle_fallback_served_for_a_capped_team_still_publishes_its_borrowing(monkeypatch: pytest.MonkeyPatch):
+ handler = _borrowing_handler(monkeypatch, borrows=True)
+ registry = _prometheus_sink(monkeypatch)
+ team: Final = _team_key(
+ team_limits={
+ "model_tpm_limit": {"other-model": 40, "shared-model": 100},
+ "team_model_limit_max_borrow_multiple": 2,
+ }
+ )
+ assert await handler.async_pre_call_hook(team, DualCache(), {"model": "other-model"}, "completion") is None
+ assert await handler.async_fallback_target_allowed(team, "shared-model", {}) is True
+ await _settle_tokens(handler, 150, model_group="shared-model")
+ shared: Final = {"model_group": "shared-model", "team": "team-a", "priority": "default"}
+ assert _sample(registry, "litellm_dynamic_limiter_share_tokens", **shared) == 100
+ assert _sample(registry, "litellm_dynamic_limiter_borrowed_tokens", **shared) == 50
+
+
@pytest.mark.asyncio
async def test_a_failing_metrics_sink_never_changes_admission(monkeypatch: pytest.MonkeyPatch):
from litellm.integrations.prometheus import PrometheusLogger
@@ -3674,8 +3820,9 @@ async def test_a_failing_metrics_sink_never_changes_admission(monkeypatch: pytes
def increment_dynamic_limiter_throttled(self, *args: object, **kwargs: object) -> None:
raise RuntimeError("metrics backend down")
- handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.1)
+ handler = _borrowing_handler(monkeypatch, borrows=True, saturation_threshold=0.0)
_prometheus_sink(monkeypatch, BrokenSink)
+ await _saturate_model(handler, 200)
await _admit_at_the_hook(handler, _team_key(), tokens=60)
await _admit_at_the_hook(handler, _team_key(), tokens=70)
assert await _team_tokens_remaining(handler) == -30
diff --git a/tests/unit/proxy/management_endpoints/management_v1/test_customer_usage.py b/tests/unit/proxy/management_endpoints/management_v1/test_customer_usage.py
new file mode 100644
index 00000000000..af16d11373b
--- /dev/null
+++ b/tests/unit/proxy/management_endpoints/management_v1/test_customer_usage.py
@@ -0,0 +1,278 @@
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from functools import reduce
+from typing import Final, cast
+
+import pytest
+from fastapi import FastAPI, Request
+from fastapi.testclient import TestClient
+from pydantic import BaseModel, ConfigDict
+
+from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.list_api.common import ManagementProblem, problem_response
+from litellm.proxy.management_endpoints.management_v1.customer_usage import get_customer_usage_prisma, router
+from litellm.proxy.utils import PrismaClient
+from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics
+
+
+class Record(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="allow")
+
+
+class DailyRecord(SpendMetrics):
+ model_config = ConfigDict(frozen=True)
+
+ id: str
+ date: str
+ customer_org_id: str
+ customer_team_id: str
+ customer_member_id: str
+ api_key: str
+ model: str | None = None
+ model_group: str | None = None
+ custom_llm_provider: str | None = None
+ mcp_namespaced_tool_name: str | None = None
+ endpoint: str | None = None
+
+
+def matches(values: Mapping[str, object], where: Mapping[str, object]) -> bool:
+ for key, condition in where.items():
+ if key == "OR":
+ if not any(matches(values, clause) for clause in condition):
+ return False
+ elif key == "AND":
+ if not all(matches(values, clause) for clause in condition):
+ return False
+ elif isinstance(condition, Mapping):
+ if "in" in condition and values.get(key) not in condition["in"]:
+ return False
+ if "not" in condition and matches(values, {key: condition["not"]}):
+ return False
+ if "gte" in condition and str(values.get(key)) < str(condition["gte"]):
+ return False
+ if "lte" in condition and str(values.get(key)) > str(condition["lte"]):
+ return False
+ elif values.get(key) != condition:
+ return False
+ return True
+
+
+@dataclass(frozen=True, slots=True)
+class Table:
+ rows: tuple[Record | DailyRecord, ...] = ()
+
+ async def find_many(
+ self,
+ where: Mapping[str, object] | None = None,
+ take: int | None = None,
+ skip: int = 0,
+ order: Mapping[str, object] | Sequence[Mapping[str, object]] | None = None,
+ ) -> tuple[Record | DailyRecord, ...]:
+ selected: Final = tuple(row for row in self.rows if matches(row.model_dump(), where or {}))
+ orders: Final = (order,) if isinstance(order, Mapping) else tuple(order or ())
+ ordered: Final = reduce(
+ lambda rows, item: tuple(
+ sorted(
+ rows,
+ key=lambda row: str(row.model_dump()[next(iter(item))]),
+ reverse=next(iter(item.values())) == "desc",
+ )
+ ),
+ reversed(orders),
+ selected,
+ )
+ return ordered[skip:] if take is None else ordered[skip : skip + take]
+
+ async def count(self, where: Mapping[str, object] | None = None) -> int:
+ return len(await self.find_many(where=where))
+
+
+@dataclass(frozen=True, slots=True)
+class Database:
+ litellm_dailycustomermemberspend: Table
+ litellm_customerorganizationtable: Table = Table(
+ (
+ Record(customer_org_id="org-a", alias="Organization A"),
+ Record(customer_org_id="org-b", alias="Organization B"),
+ )
+ )
+ litellm_customerteamtable: Table = Table(
+ (Record(customer_team_id="team-a", alias="Product"), Record(customer_team_id="team-b", alias="Operations"))
+ )
+ litellm_customermembertable: Table = Table(
+ (
+ Record(customer_member_id="member-a", alias="Member A"),
+ Record(customer_member_id="member-b", alias="Member B"),
+ )
+ )
+ litellm_verificationtoken: Table = Table(
+ (
+ Record(token="hash-a", key_alias="Support", team_id=None, user_id=None, metadata=None),
+ Record(token="hash-b", key_alias="Search", team_id=None, user_id=None, metadata=None),
+ )
+ )
+ litellm_deletedverificationtoken: Table = Table()
+ litellm_usertable: Table = Table()
+
+
+@dataclass(frozen=True, slots=True)
+class Client:
+ db: Database
+
+
+def usage_row(
+ *,
+ id: str = "usage-a",
+ date: str = "2026-01-01",
+ customer_org_id: str = "org-a",
+ customer_team_id: str = "team-a",
+ customer_member_id: str = "member-a",
+ api_key: str = "hash-a",
+ model: str = "model-a",
+ model_group: str = "alias-a",
+ provider: str = "provider-a",
+ spend: float = 2.0,
+) -> DailyRecord:
+ return DailyRecord(
+ id=id,
+ date=date,
+ customer_org_id=customer_org_id,
+ customer_team_id=customer_team_id,
+ customer_member_id=customer_member_id,
+ api_key=api_key,
+ model=model,
+ model_group=model_group,
+ custom_llm_provider=provider,
+ spend=spend,
+ prompt_tokens=10,
+ completion_tokens=5,
+ total_tokens=15,
+ api_requests=3,
+ successful_requests=2,
+ failed_requests=1,
+ )
+
+
+def client(rows: tuple[DailyRecord, ...] = (), role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> TestClient:
+ app: Final = FastAPI()
+ app.include_router(router)
+
+ @app.exception_handler(ManagementProblem)
+ async def problem(request: Request, exc: ManagementProblem):
+ return problem_response(exc.problem)
+
+ auth: Final = UserAPIKeyAuth(user_id="admin", user_role=role)
+ prisma: Final = cast(PrismaClient, Client(Database(Table(rows)))) # cast-ok: in-memory Prisma boundary
+ app.dependency_overrides[user_api_key_auth] = lambda: auth
+ app.dependency_overrides[get_customer_usage_prisma] = lambda: prisma
+ return TestClient(app)
+
+
+DATES: Final = {"start_date": "2026-01-01", "end_date": "2026-01-03"}
+SCOPES: Final = (
+ ("customer_organizations", "customer_org_ids", "exclude_customer_org_ids", "org-a", "org-b", "Organization A"),
+ ("customer_teams", "customer_team_ids", "exclude_customer_team_ids", "team-a", "team-b", "Product"),
+ ("customer_members", "customer_member_ids", "exclude_customer_member_ids", "member-a", "member-b", "Member A"),
+)
+
+
+@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
+def test_standard_daily_activity_filters_entities_and_preserves_aliases_and_dimensions(
+ resource: str, parameter: str, excluded: str, first: str, second: str, label: str
+) -> None:
+ rows: Final = (
+ usage_row(),
+ usage_row(id="usage-c", date="2026-01-03", model_group="alias-b", provider="provider-b", spend=4),
+ usage_row(
+ id="usage-b", customer_org_id="org-b", customer_team_id="team-b", customer_member_id="member-b", spend=10
+ ),
+ usage_row(id="outside-range", date="2025-12-31", spend=50),
+ )
+ response: Final = client(rows).get(f"/management/v1/{resource}/daily/activity", params={**DATES, parameter: first})
+ assert response.status_code == 200, response.text
+ body: Final = response.json()
+ assert set(body) == {"results", "metadata"}
+ assert body["metadata"]["total_spend"] == 6
+ assert body["metadata"]["total_tokens"] == 30
+ assert body["metadata"]["total_api_requests"] == 6
+ assert body["metadata"]["has_more"] is False
+ day: Final = body["results"][0]
+ assert day["breakdown"]["entities"][first]["metadata"]["alias"] == label
+ assert day["breakdown"]["models"]["model-a"]["metrics"]["spend"] == 4
+ assert day["breakdown"]["model_groups"]["alias-b"]["metrics"]["spend"] == 4
+ assert day["breakdown"]["providers"]["provider-b"]["metrics"]["spend"] == 4
+ assert day["breakdown"]["api_keys"]["hash-a"]["metadata"]["key_alias"] == "Support"
+
+
+@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
+def test_inclusion_and_exclusion_lists_are_comma_separated(
+ resource: str, parameter: str, excluded: str, first: str, second: str, label: str
+) -> None:
+ rows: Final = (
+ usage_row(),
+ usage_row(
+ id="usage-b", customer_org_id="org-b", customer_team_id="team-b", customer_member_id="member-b", spend=10
+ ),
+ )
+ response: Final = client(rows).get(
+ f"/management/v1/{resource}/daily/activity", params={**DATES, parameter: f"{first},{second}", excluded: second}
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["metadata"]["total_spend"] == 2
+
+
+@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
+def test_standard_model_key_and_page_filters_use_the_shared_response_contract(
+ resource: str, parameter: str, excluded: str, first: str, second: str, label: str
+) -> None:
+ rows: Final = (
+ usage_row(id="page-a"),
+ usage_row(id="page-b", date="2026-01-02", spend=4),
+ usage_row(id="other-model", model="model-b", spend=20),
+ usage_row(id="other-key", api_key="hash-b", spend=30),
+ )
+ response: Final = client(rows).get(
+ f"/management/v1/{resource}/daily/activity",
+ params={**DATES, parameter: first, "model": "model-a", "api_key": "hash-a", "page": 1, "page_size": 1},
+ )
+ assert response.status_code == 200, response.text
+ body: Final = response.json()
+ assert body["metadata"]["page"] == 1
+ assert body["metadata"]["total_pages"] == 2
+ assert body["metadata"]["has_more"] is True
+ assert body["metadata"]["total_spend"] == 4
+ assert body["results"][0]["date"] == "2026-01-02"
+ next_page: Final = client(rows).get(
+ f"/management/v1/{resource}/daily/activity",
+ params={**DATES, parameter: first, "model": "model-a", "api_key": "hash-a", "page": 2, "page_size": 1},
+ )
+ assert next_page.status_code == 200, next_page.text
+ assert next_page.json()["metadata"]["total_spend"] == 2
+ assert next_page.json()["metadata"]["has_more"] is False
+
+
+@pytest.mark.parametrize("resource,parameter,excluded,first,second,label", SCOPES)
+@pytest.mark.parametrize("role", (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
+def test_all_hierarchy_daily_activity_routes_are_admin_only(
+ resource: str, parameter: str, excluded: str, first: str, second: str, label: str, role: LitellmUserRoles
+) -> None:
+ response: Final = client(role=role).get(f"/management/v1/{resource}/daily/activity", params=DATES)
+ assert response.status_code == 403, response.text
+
+
+@pytest.mark.parametrize("params", ({}, {"start_date": "invalid", "end_date": "2026-01-03"}, {**DATES, "page": 0}))
+def test_shared_helper_rejects_missing_dates_invalid_dates_and_nonpositive_pagination(
+ params: Mapping[str, object],
+) -> None:
+ response: Final = client().get("/management/v1/customer_members/daily/activity", params=params)
+ assert response.status_code == 400, response.text
+
+
+def test_unmatched_entities_return_the_standard_empty_response() -> None:
+ response: Final = client((usage_row(),)).get(
+ "/management/v1/customer_members/daily/activity", params={**DATES, "customer_member_ids": "missing"}
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["results"] == []
+ assert response.json()["metadata"]["total_spend"] == 0
diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py
index ea36ffad044..e7bc5675e06 100644
--- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -41,6 +41,7 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
)
from litellm.proxy.proxy_server import app
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
+from litellm.types.proxy.common_proxy_errors import CommonProxyErrors
from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains
from tests.unit.proxy.management_endpoints.jwt_key_mapping_doubles import (
CascadingJWTMappingTable,
@@ -50,6 +51,30 @@ from tests.unit.proxy.management_endpoints.jwt_key_mapping_doubles import (
client = TestClient(app)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("binding", ["customer_org_id", "customer_team_id", "customer_member_id"])
+async def test_new_user_cannot_mint_customer_bound_keys_without_proxy_admin(binding: str) -> None:
+ with pytest.raises(HTTPException) as caught:
+ await new_user(
+ data=NewUserRequest(metadata={binding: "another-customer"}),
+ user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER),
+ )
+ assert caught.value.status_code == 403
+ assert caught.value.detail == "Only proxy admins can change a key's customer organization, team, or member binding."
+
+
+@pytest.mark.asyncio
+async def test_new_user_reports_missing_database_after_customer_binding_validation(mocker: MockerFixture) -> None:
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", None)
+ with pytest.raises(ProxyException) as caught:
+ await new_user(
+ data=NewUserRequest(metadata={"customer_org_id": "org-1"}),
+ user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+ assert str(caught.value.code) == "400"
+ assert caught.value.message == CommonProxyErrors.db_not_connected_error.value
+
+
@pytest.mark.parametrize("user_id", ["", " \t\n", "user\x00id", 123, True, [], {}])
def test_new_user_rejects_invalid_custom_id(user_id: object) -> None:
with pytest.raises(ValidationError) as exc:
diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py
index c67cb974583..9e0e123436a 100644
--- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py
+++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py
@@ -5,7 +5,7 @@ from collections.abc import Mapping
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
-from typing import Final
+from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, call, patch
from uuid import UUID, uuid4
@@ -24618,3 +24618,89 @@ def test_user_new_without_a_key_does_not_check_guardrail_names(spend_permission_
assert response.status_code == 200, response.text
find_many.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("field", ("customer_org_id", "customer_team_id", "customer_member_id"))
+async def test_customer_binding_requires_admin_on_key_creation(field: str) -> None:
+ with pytest.raises(HTTPException) as rejected:
+ await _common_key_generation_helper(
+ data=GenerateKeyRequest(metadata={field: "customer-id"}),
+ user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
+ litellm_changed_by=None,
+ team_table=None,
+ )
+ assert rejected.value.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("request_type", ("key", "user"))
+async def test_direct_key_mint_cannot_bypass_customer_binding_authorization(request_type: Literal["key", "user"]) -> None:
+ with pytest.raises(HTTPException) as rejected:
+ await generate_key_helper_fn(request_type=request_type, metadata={"customer_org_id": "another-customer"})
+ assert rejected.value.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ("update", "regenerate"))
+@pytest.mark.parametrize("field", ("customer_org_id", "customer_team_id", "customer_member_id"))
+@pytest.mark.parametrize("requested", ("another-customer-id", None))
+async def test_customer_binding_cannot_be_reassigned_or_cleared_by_key_holder(
+ operation: Literal["update", "regenerate"], field: str, requested: str | None
+) -> None:
+ existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={field: "customer-id"})
+ data: Final = (
+ UpdateKeyRequest(key="hashed-key", metadata={field: requested})
+ if operation == "update"
+ else RegenerateKeyRequest(metadata={field: requested})
+ )
+ with pytest.raises(HTTPException) as rejected:
+ await prepare_key_update_data(
+ data=data,
+ existing_key_row=existing,
+ user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
+ )
+ assert rejected.value.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ("update", "regenerate"))
+@pytest.mark.parametrize("metadata", ({"application": "support"}, {}, None))
+async def test_unrelated_key_metadata_changes_preserve_existing_customer_binding(
+ operation: Literal["update", "regenerate"], metadata: dict[str, object] | None
+) -> None:
+ bindings: Final = {"customer_org_id": "org-id", "customer_team_id": "team-id", "customer_member_id": "member-id"}
+ existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={**bindings, "old": "discard"})
+ data: Final = (
+ UpdateKeyRequest(key="hashed-key", metadata=metadata)
+ if operation == "update"
+ else RegenerateKeyRequest(metadata=metadata)
+ )
+ actual: Final = await prepare_key_update_data(
+ data=data,
+ existing_key_row=existing,
+ user_api_key_dict=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
+ )
+ assert actual["metadata"] == {**(metadata or {}), **bindings}
+ assert existing.metadata == {**bindings, "old": "discard"}
+ assert data.metadata == metadata
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ("update", "regenerate"))
+@pytest.mark.parametrize("metadata", ({"customer_org_id": "another-org"}, {}, None))
+async def test_proxy_admin_can_replace_or_clear_customer_key_binding(
+ operation: Literal["update", "regenerate"], metadata: dict[str, object] | None
+) -> None:
+ existing: Final = LiteLLM_VerificationToken(token="hashed-key", metadata={"customer_org_id": "org-id"})
+ data: Final = (
+ UpdateKeyRequest(key="hashed-key", metadata=metadata)
+ if operation == "update"
+ else RegenerateKeyRequest(metadata=metadata)
+ )
+ actual: Final = await prepare_key_update_data(
+ data=data,
+ existing_key_row=existing,
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+ assert actual["metadata"] == metadata
diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py
index 337aa2939dd..f4138260e28 100644
--- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -4097,7 +4097,7 @@ class TestSpendLogsPayload:
"model": "gpt-4o",
"user": "",
"team_id": "",
- "metadata": '{"user_api_key_user_email": null, "actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "litellm_roi_estimator": false, "billed_retry_attempt": false, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
+ "metadata": '{"user_api_key_customer_usage": null, "user_api_key_user_email": null, "actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "litellm_roi_estimator": false, "billed_retry_attempt": false, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,
diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py
index 71efa1352e4..23748790869 100644
--- a/tests/unit/test_cost_calculator.py
+++ b/tests/unit/test_cost_calculator.py
@@ -2,7 +2,7 @@ import json
import asyncio
import datetime
import time
-from collections.abc import Iterator, Mapping
+from collections.abc import Callable, Iterator, Mapping
from pathlib import Path
from types import MappingProxyType, SimpleNamespace
from typing import Final, Literal, cast
@@ -526,6 +526,204 @@ def test_completion_cost_bills_priced_response_model_over_priced_requested_model
assert cost == pytest.approx(_token_rates_cost(litellm.model_cost["openai/sol-requested-test-2099-01-01"], usage))
+_BEDROCK_PROFILE_GLOBAL_RATES: Final = MappingProxyType(
+ {
+ "input_cost_per_token": 1e-6,
+ "output_cost_per_token": 5e-6,
+ "cache_read_input_token_cost": 1e-7,
+ "cache_creation_input_token_cost": 1.25e-6,
+ }
+)
+_BEDROCK_PROFILE_MODEL: Final = "anthropic.bedrock-profile-test-v1:0"
+_BEDROCK_PROFILE_RATE_MULTIPLIERS: Final = MappingProxyType(
+ {
+ _BEDROCK_PROFILE_MODEL: 1.0,
+ f"eu.{_BEDROCK_PROFILE_MODEL}": 1.1,
+ f"us.{_BEDROCK_PROFILE_MODEL}": 1.2,
+ f"jp.{_BEDROCK_PROFILE_MODEL}": 1.3,
+ f"global.{_BEDROCK_PROFILE_MODEL}": 0.9,
+ "anthropic.bedrock-profile-other-test-v1:0": 3.0,
+ "anthropic.bedrock-profile-unpriced-test-v1:0": 1.0,
+ "bedrock-profile-router-test": 5.0,
+ }
+)
+_BEDROCK_PROFILE_CACHE_USAGE: Final = MappingProxyType(
+ {"cache_creation_input_tokens": 2000, "cache_read_input_tokens": 24000}
+)
+
+
+@pytest.fixture
+def _bedrock_profile_rows(_local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch) -> None:
+ for key, multiplier in _BEDROCK_PROFILE_RATE_MULTIPLIERS.items():
+ monkeypatch.setitem(
+ litellm.model_cost,
+ key,
+ {
+ "litellm_provider": "bedrock_converse",
+ "mode": "chat",
+ **{field: rate * multiplier for field, rate in _BEDROCK_PROFILE_GLOBAL_RATES.items()},
+ },
+ )
+ monkeypatch.setitem(
+ litellm.model_cost,
+ "eu.anthropic.bedrock-profile-unpriced-test-v1:0",
+ {"litellm_provider": "bedrock_converse", "mode": "chat"},
+ )
+
+
+def _bedrock_messages_body(model: str) -> dict[str, object]:
+ return {
+ "id": "msg_bdrk_profile",
+ "type": "message",
+ "role": "assistant",
+ "model": model,
+ "content": [{"type": "text", "text": "hi"}],
+ "stop_reason": "end_turn",
+ "usage": {"input_tokens": 18, "output_tokens": 4, **_BEDROCK_PROFILE_CACHE_USAGE},
+ }
+
+
+def _bedrock_stream_response(model: str) -> ModelResponse:
+ cached: Final = _BEDROCK_PROFILE_CACHE_USAGE["cache_read_input_tokens"]
+ written: Final = _BEDROCK_PROFILE_CACHE_USAGE["cache_creation_input_tokens"]
+ return ModelResponse(
+ model=model,
+ choices=[],
+ usage=Usage(
+ prompt_tokens=18 + cached + written,
+ completion_tokens=4,
+ total_tokens=22 + cached + written,
+ cache_creation_input_tokens=written,
+ cache_read_input_tokens=cached,
+ prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=cached),
+ ),
+ )
+
+
+def _bedrock_private_route_response(model: str) -> ModelResponse:
+ response: Final = _bedrock_stream_response("bedrock-profile-alias")
+ response._hidden_params = {"custom_llm_provider": "bedrock", "provider_response_model": model}
+ return response
+
+
+def _bedrock_global_cost(served_shape: Callable[[str], object]) -> float:
+ return completion_cost(
+ completion_response=served_shape(_BEDROCK_PROFILE_MODEL),
+ model=f"bedrock/{_BEDROCK_PROFILE_MODEL}",
+ custom_llm_provider="bedrock",
+ )
+
+
+_BEDROCK_SERVED_SHAPES: Final = (
+ pytest.param(_bedrock_messages_body, id="non-streaming"),
+ pytest.param(_bedrock_stream_response, id="streaming"),
+ pytest.param(_bedrock_private_route_response, id="private-provider-response-model"),
+)
+
+
+@pytest.mark.parametrize("served_shape", _BEDROCK_SERVED_SHAPES)
+@pytest.mark.parametrize("route", ["bedrock/", "bedrock/invoke/"])
+@pytest.mark.parametrize("region", ["eu", "us", "jp"])
+def test_completion_cost_bills_bedrock_cross_region_profile_when_bedrock_serves_the_region_stripped_id(
+ _bedrock_profile_rows: None, region: str, route: str, served_shape: Callable[[str], object]
+) -> None:
+ profile: Final = f"{region}.{_BEDROCK_PROFILE_MODEL}"
+
+ cost: Final = completion_cost(
+ completion_response=served_shape(_BEDROCK_PROFILE_MODEL),
+ model=f"{route}{profile}",
+ custom_llm_provider="bedrock",
+ )
+
+ assert cost == pytest.approx(_bedrock_global_cost(served_shape) * _BEDROCK_PROFILE_RATE_MULTIPLIERS[profile])
+
+
+@pytest.mark.parametrize("served_shape", _BEDROCK_SERVED_SHAPES)
+@pytest.mark.parametrize(
+ ("deployment", "served_model"),
+ [
+ pytest.param(f"bedrock/eu.{_BEDROCK_PROFILE_MODEL}", f"us.{_BEDROCK_PROFILE_MODEL}", id="other-region"),
+ pytest.param(f"bedrock/eu.{_BEDROCK_PROFILE_MODEL}", f"global.{_BEDROCK_PROFILE_MODEL}", id="global-profile"),
+ pytest.param(
+ f"bedrock/jp.{_BEDROCK_PROFILE_MODEL}", "anthropic.bedrock-profile-other-test-v1:0", id="other-model"
+ ),
+ pytest.param(
+ "bedrock/eu.anthropic.bedrock-profile-unpriced-test-v1:0",
+ "anthropic.bedrock-profile-unpriced-test-v1:0",
+ id="unpriced-profile",
+ ),
+ ],
+)
+def test_completion_cost_keeps_the_served_bedrock_row_unless_it_is_the_region_stripped_profile(
+ _bedrock_profile_rows: None, deployment: str, served_model: str, served_shape: Callable[[str], object]
+) -> None:
+ cost: Final = completion_cost(
+ completion_response=served_shape(served_model),
+ model=deployment,
+ custom_llm_provider="bedrock",
+ )
+
+ assert cost == pytest.approx(_bedrock_global_cost(served_shape) * _BEDROCK_PROFILE_RATE_MULTIPLIERS[served_model])
+
+
+@pytest.mark.parametrize(
+ ("base_model", "custom_pricing", "router_model_id", "priced_row"),
+ [
+ pytest.param(
+ "bedrock/anthropic.bedrock-profile-other-test-v1:0",
+ None,
+ None,
+ "anthropic.bedrock-profile-other-test-v1:0",
+ id="base-model",
+ ),
+ pytest.param(None, True, "bedrock-profile-router-test", "bedrock-profile-router-test", id="custom-pricing"),
+ ],
+)
+def test_completion_cost_explicit_pricing_precedes_the_bedrock_cross_region_profile(
+ _bedrock_profile_rows: None,
+ base_model: str | None,
+ custom_pricing: bool | None,
+ router_model_id: str | None,
+ priced_row: str,
+) -> None:
+ cost: Final = completion_cost(
+ completion_response=_bedrock_messages_body(_BEDROCK_PROFILE_MODEL),
+ model=f"bedrock/eu.{_BEDROCK_PROFILE_MODEL}",
+ custom_llm_provider="bedrock",
+ base_model=base_model,
+ custom_pricing=custom_pricing,
+ router_model_id=router_model_id,
+ )
+
+ assert cost == pytest.approx(
+ _bedrock_global_cost(_bedrock_messages_body) * _BEDROCK_PROFILE_RATE_MULTIPLIERS[priced_row]
+ )
+
+
+def test_completion_cost_keeps_the_served_row_for_a_region_dotted_name_outside_bedrock(
+ _local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setitem(
+ litellm.model_cost,
+ "openai/eu.profile-dotted-test",
+ {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": 3e-6, "output_cost_per_token": 7e-6},
+ )
+ monkeypatch.setitem(
+ litellm.model_cost,
+ "openai/profile-dotted-test",
+ {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": 5e-6, "output_cost_per_token": 11e-6},
+ )
+ usage: Final = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
+
+ cost: Final = completion_cost(
+ completion_response=ModelResponse(model="profile-dotted-test", choices=[], usage=usage),
+ model="openai/eu.profile-dotted-test",
+ custom_llm_provider="openai",
+ )
+
+ assert cost == pytest.approx(_token_rates_cost(litellm.model_cost["openai/profile-dotted-test"], usage))
+
+
def test_completion_cost_keeps_response_pricing_when_requested_alias_has_no_rates(
_local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/uv.lock b/uv.lock
index 6dc41480bf5..9c23d77ea3b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -5032,7 +5032,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.110.0"
+version = "1.111.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -5571,7 +5571,7 @@ provides-extras = ["test"]
[[package]]
name = "litellm-enterprise"
-version = "0.1.83"
+version = "0.1.84"
source = { editable = "litellm/enterprise" }
[[package]]