diff --git a/litellm/types/utils.py b/litellm/types/utils.py index a97e35a9021..054a0a1c034 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3900,6 +3900,7 @@ class MirroredPricingParams(LiteLLMBaseModel): output_cost_per_character: float | None = None cache_read_input_token_cost: float | None = None cache_creation_input_token_cost: float | None = None + cache_creation_input_token_cost_above_1hr: float | None = None tiered_pricing: list[dict[str, Any]] | None = None @@ -3927,7 +3928,6 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): input_cost_per_token_priority: float | None = None input_cost_per_token_balanced: float | None = None input_cost_per_token_ultrafast: float | None = None - cache_creation_input_token_cost_above_1hr: float | None = None cache_creation_input_token_cost_above_100k_tokens: float | None = None cache_creation_input_token_cost_above_1hr_above_100k_tokens: float | None = None cache_creation_input_token_cost_above_200k_tokens: float | None = None diff --git a/tests/e2e/ui/tests/modelsPage/cacheWriteTiers.spec.ts b/tests/e2e/ui/tests/modelsPage/cacheWriteTiers.spec.ts new file mode 100644 index 00000000000..7d0f02209cf --- /dev/null +++ b/tests/e2e/ui/tests/modelsPage/cacheWriteTiers.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Role, users } from "../../fixtures/users"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage } from "../../helpers/navigation"; +import { captureRequestBody, readBack } from "../../helpers/roundTrip"; + +const FIVE_MINUTE_PER_MILLION = 3.75; +const ONE_HOUR_PER_MILLION = 7.5; +const perToken = (perMillion: number): number => perMillion / 1_000_000; +const ONE_HOUR_LABEL = /^Cache Write Cost \(1 hour, per 1M tokens\)/; + +type Deployment = { + model_name?: string; + model_info?: { id?: string }; + litellm_params: { + cache_creation_input_token_cost?: number; + cache_creation_input_token_cost_above_1hr?: number; + }; +}; + +const masterKey = users[Role.ProxyAdmin].password; +const auth = { Authorization: `Bearer ${masterKey}` }; + +async function deleteModel(page: PlaywrightPage, id: string) { + await page.request.post("/model/delete", { headers: auth, data: { id } }); +} + +async function readDeployment(page: PlaywrightPage, modelId: string): Promise { + const body = await readBack<{ data: Deployment[] }>(page, `/model/info?litellm_model_id=${modelId}`); + return body.data[0]; +} + +async function selectProvider(page: PlaywrightPage, providerName: string) { + const providerDropdown = page.getByRole("combobox", { name: "Provider", exact: true }); + await providerDropdown.click(); + await providerDropdown.fill(providerName); + await page.getByRole("option").filter({ hasText: providerName }).first().click(); + await expect(providerDropdown).toHaveValue(providerName); +} + +test.describe("Cache write cost tiers", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + let createdModelId: string | null = null; + + test.afterEach(async ({ page }) => { + if (createdModelId) { + await deleteModel(page, createdModelId); + createdModelId = null; + } + }); + + test("the edit form saves the 1 hour rate next to the 5 minute rate and the detail view shows both", async ({ + page, + }) => { + const modelName = `e2e-cache-tiers-edit-${Date.now()}`; + const created = await page.request.post("/model/new", { + headers: auth, + data: { + model_name: modelName, + litellm_params: { + model: "anthropic/claude-sonnet-4-6", + api_key: "sk-e2e-not-used", + cache_creation_input_token_cost: perToken(FIVE_MINUTE_PER_MILLION), + }, + model_info: {}, + }, + }); + expect(created.ok(), `POST /model/new for ${modelName}`).toBe(true); + createdModelId = (await created.json()).model_info?.id; + expect(createdModelId, "model id from /model/new").toBeTruthy(); + + await page.goto("/ui"); + await page.getByText("Models + Endpoints").click(); + const modelIdCell = page.getByTestId(`model-id-${createdModelId}`); + await expect(modelIdCell).toBeVisible({ timeout: 15_000 }); + await modelIdCell.click(); + await expect(page.getByText("Back to Models").first()).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Edit Settings" }).click(); + const oneHour = page.getByLabel(ONE_HOUR_LABEL); + await oneHour.waitFor({ timeout: 15_000 }); + await oneHour.fill(String(ONE_HOUR_PER_MILLION)); + + const patchBody = await captureRequestBody( + page, + { method: "PATCH", urlIncludes: `/model/${createdModelId}/update` }, + async () => { + await page.getByRole("button", { name: "Save Changes" }).click(); + }, + ); + expect(patchBody.litellm_params.cache_creation_input_token_cost_above_1hr, "1 hour rate sent per token").toBe( + perToken(ONE_HOUR_PER_MILLION), + ); + await expect(page.getByText("Model settings updated successfully")).toBeVisible({ timeout: 10_000 }); + + const stored = await readDeployment(page, createdModelId!); + expect(stored?.litellm_params?.cache_creation_input_token_cost_above_1hr, "1 hour rate persisted").toBe( + perToken(ONE_HOUR_PER_MILLION), + ); + expect(stored?.litellm_params?.cache_creation_input_token_cost, "5 minute rate untouched").toBe( + perToken(FIVE_MINUTE_PER_MILLION), + ); + + await expect(page.getByText(ONE_HOUR_LABEL)).toBeVisible(); + await expect(page.getByText(ONE_HOUR_PER_MILLION.toFixed(4))).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(FIVE_MINUTE_PER_MILLION.toFixed(4))).toBeVisible(); + }); + + test("Add Model sends both cache write tiers per token", async ({ page }) => { + const publicName = `e2e-cache-tiers-add-${Date.now()}`; + await navigateToPage(page, Page.Models); + await page.getByRole("tab", { name: "Add Model" }).click(); + await selectProvider(page, "Anthropic"); + + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "Custom Model Name (Enter below)" }).click(); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Enter custom model name").fill(publicName); + await page.locator("#api_key").fill("sk-e2e-not-used"); + + await page.getByText("Advanced Settings").click(); + await page.getByRole("switch", { name: "Custom Pricing" }).click(); + await page.getByLabel(/^Input Cost \(per 1M tokens\)/).fill("3"); + await page.getByLabel(/^Output Cost \(per 1M tokens\)/).fill("15"); + await page.getByLabel(/^Cache Write Cost \(per 1M tokens\)/).fill(String(FIVE_MINUTE_PER_MILLION)); + await page.getByLabel(ONE_HOUR_LABEL).fill(String(ONE_HOUR_PER_MILLION)); + + const body = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => { + await page.getByTestId("add-model-btn").click(); + }); + await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 }); + const listed = await readBack<{ data: Deployment[] }>(page, "/v2/model/info"); + createdModelId = listed.data.find((row) => row.model_name === publicName)?.model_info?.id ?? null; + + expect(body.model_name).toBe(publicName); + expect(body.litellm_params.cache_creation_input_token_cost, "5 minute rate sent per token").toBe( + perToken(FIVE_MINUTE_PER_MILLION), + ); + expect(body.litellm_params.cache_creation_input_token_cost_above_1hr, "1 hour rate sent per token").toBe( + perToken(ONE_HOUR_PER_MILLION), + ); + expect(createdModelId, "the model landed in the database").toBeTruthy(); + }); +}); diff --git a/tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts b/tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts index c532641b238..34a93c7ce14 100644 --- a/tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts @@ -87,7 +87,7 @@ test.describe("Clear custom pricing on a deployment", () => { // Both cache fields share the same placeholder ("Defaults to Input Cost if blank"), // so disambiguate via their labels. const cacheReadCost = page.getByLabel(/Cache Read Cost/); - const cacheWriteCost = page.getByLabel(/Cache Write Cost/); + const cacheWriteCost = page.getByLabel("Cache Write Cost (per 1M tokens)", { exact: true }); await inputCost.waitFor({ timeout: 15_000 }); for (const field of [inputCost, outputCost, cacheReadCost, cacheWriteCost]) { await field.click({ clickCount: 3 }); diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index beffe454b8d..ad75344afba 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -3758,6 +3758,43 @@ class TestUpdateDBModelClearPricing: assert "cache_creation_input_token_cost" not in params assert "cache_creation_input_token_cost" not in info + def test_clear_cache_write_1h_cost_removes_from_both_blobs(self): + """The dashboard clears the 1 hour cache-write tier with an explicit null, which only + takes effect for fields in SPECIAL_MODEL_INFO_PARAMS.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import ( + Deployment, + LiteLLM_Params, + ModelInfo, + updateLiteLLMParams, + ) + + db_model = Deployment( + model_name="anthropic/*", + litellm_params=LiteLLM_Params( + model="anthropic/*", + cache_creation_input_token_cost=0.00000375, + cache_creation_input_token_cost_above_1hr=0.000006, + ), + model_info=ModelInfo(id="dep-cache-write-1h-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams(cache_creation_input_token_cost_above_1hr=None) + ), + ) + + params = json.loads(result["litellm_params"]) + info = json.loads(result["model_info"]) + assert "cache_creation_input_token_cost_above_1hr" not in params + assert "cache_creation_input_token_cost_above_1hr" not in info + assert params["cache_creation_input_token_cost"] == 0.00000375 + assert info["cache_creation_input_token_cost"] == 0.00000375 + def test_clear_cache_read_preserves_other_pricing(self): """Clearing cache_read must not touch input/output cost overrides.""" from litellm.proxy.management_endpoints.model_management_endpoints import ( diff --git a/tests/unit/types/test_router.py b/tests/unit/types/test_router.py index 6188bb966ee..8092437fbd3 100644 --- a/tests/unit/types/test_router.py +++ b/tests/unit/types/test_router.py @@ -63,6 +63,7 @@ def test_custom_pricing_params_keeps_every_field_it_had(): "output_cost_per_character", "cache_read_input_token_cost", "cache_creation_input_token_cost", + "cache_creation_input_token_cost_above_1hr", "cost_per_second", "input_cost_per_second", "cache_read_input_token_cost_flex", diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index c7860f35100..125857b9a94 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -63,20 +63,21 @@ const PTU_EDIT_FIELDS: PtuEditField[] = [ { name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" }, ]; -export type TouchedPricingField = "input_cost" | "output_cost" | "cache_read_cost" | "cache_write_cost"; - -const PRICING_FIELDS: readonly TouchedPricingField[] = [ +const PRICING_FIELDS = [ "input_cost", "output_cost", "cache_read_cost", "cache_write_cost", + "cache_write_cost_1h", ] as const; +export type TouchedPricingField = (typeof PRICING_FIELDS)[number]; -const COST_SOURCES: Record = { - input_cost: { param: "input_cost_per_token", info: "input_cost_per_token" }, - output_cost: { param: "output_cost_per_token", info: "output_cost_per_token" }, - cache_read_cost: { param: "cache_read_input_token_cost", info: "cache_read_input_token_cost" }, - cache_write_cost: { param: "cache_creation_input_token_cost", info: "cache_creation_input_token_cost" }, +const COST_SOURCES: Record = { + input_cost: "input_cost_per_token", + output_cost: "output_cost_per_token", + cache_read_cost: "cache_read_input_token_cost", + cache_write_cost: "cache_creation_input_token_cost", + cache_write_cost_1h: "cache_creation_input_token_cost_above_1hr", }; export interface ModelEditFormValues { @@ -94,6 +95,7 @@ export interface ModelEditFormValues { output_cost?: string | number | null; cache_read_cost?: string | number | null; cache_write_cost?: string | number | null; + cache_write_cost_1h?: string | number | null; ptu_count?: string | number | null; cost_per_ptu_per_hour?: string | number | null; ptu_effective_from?: Dayjs | null; @@ -131,6 +133,7 @@ const modelEditShape = { output_cost: scalar, cache_read_cost: scalar, cache_write_cost: scalar, + cache_write_cost_1h: scalar, ptu_count: scalar, cost_per_ptu_per_hour: scalar, ptu_effective_from: z.custom().nullish(), @@ -243,6 +246,10 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool localModelData.litellm_params?.cache_creation_input_token_cost, localModelData.model_info?.cache_creation_input_token_cost, ), + cache_write_cost_1h: perMillionTokens( + localModelData.litellm_params?.cache_creation_input_token_cost_above_1hr, + localModelData.model_info?.cache_creation_input_token_cost_above_1hr, + ), cache_control: localModelData.litellm_params?.cache_control_injection_points ? true : false, cache_control_injection_points: localModelData.litellm_params?.cache_control_injection_points || [], model_access_group: Array.isArray(localModelData.model_info?.access_groups) @@ -271,8 +278,8 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool }); const displayCost = (localModelData: any, field: TouchedPricingField): string => { - const { param, info } = COST_SOURCES[field]; - const rate = localModelData?.litellm_params?.[param] ?? localModelData?.model_info?.[info]; + const key = COST_SOURCES[field]; + const rate = localModelData?.litellm_params?.[key] ?? localModelData?.model_info?.[key]; return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set"; }; @@ -527,6 +534,12 @@ const ModelInfoEditForm: React.FC = ({ "Defaults to Input Cost if blank", "If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).", )} + {pricingField( + "cache_write_cost_1h", + "Cache Write Cost (1 hour, per 1M tokens)", + "Defaults to the cost map 1 hour rate, else Cache Write Cost", + "Rate for prompt cache writes with a 1 hour TTL. If left blank on save, known models use their cost map 1 hour rate, otherwise Cache Write Cost.", + )} {textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)} {textField( diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx index ceb9d1ca713..15a13219d18 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx @@ -52,6 +52,17 @@ describe("AdvancedSettings", () => { }); }); + it("offers both cache write tiers once custom pricing is on", async () => { + renderAdvancedSettings(); + act(() => { + fireEvent.click(screen.getByText("Advanced Settings")); + }); + fireEvent.click(await screen.findByRole("switch", { name: "Custom Pricing" })); + + expect(await screen.findByLabelText(/^Cache Write Cost \(per 1M tokens\)/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^Cache Write Cost \(1 hour, per 1M tokens\)/)).toBeInTheDocument(); + }); + it("hides every PTU field when PTU cost attribution is disabled", async () => { renderAdvancedSettings(); act(() => { diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index eb12e49ca89..6025db8e2e2 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -49,6 +49,7 @@ const USAGE_COST_FIELDS = [ "output_cost_per_token", "cache_read_input_token_cost", "cache_creation_input_token_cost", + "cache_creation_input_token_cost_above_1hr", "input_cost_per_second", ]; @@ -393,6 +394,25 @@ const AdvancedSettings: React.FC = ({ /> )} + + {(control) => ( + + )} + ) : ( { expect(deployment.modelInfoObj.team_id).toBe("team-123"); }); + it("converts both cache write tiers from per-million to per-token", async () => { + const formValues = { + model_mappings: [{ public_name: "claude-sonnet", litellm_model: "anthropic/claude-sonnet-4-6" }], + model_name: "anthropic/claude-sonnet-4-6", + custom_pricing: true, + input_cost_per_token: "3", + output_cost_per_token: "15", + cache_creation_input_token_cost: "3.75", + cache_creation_input_token_cost_above_1hr: "6", + }; + + const [deployment] = (await prepareModelAddRequest({ ...formValues }, "token", null))!; + + expect(deployment.litellmParamsObj.cache_creation_input_token_cost).toBeCloseTo(0.00000375, 12); + expect(deployment.litellmParamsObj.cache_creation_input_token_cost_above_1hr).toBeCloseTo(0.000006, 12); + }); + + it("converts the pricing once when several models are added together", async () => { + const formValues = { + model_mappings: [ + { public_name: "claude-sonnet", litellm_model: "anthropic/claude-sonnet-4-6" }, + { public_name: "claude-opus", litellm_model: "anthropic/claude-opus-4-2" }, + ], + model_name: "anthropic/claude-sonnet-4-6", + custom_pricing: true, + input_cost_per_token: "3", + cache_creation_input_token_cost: "3.75", + cache_creation_input_token_cost_above_1hr: "6", + }; + + const deployments = (await prepareModelAddRequest({ ...formValues }, "token", null))!; + + expect(deployments).toHaveLength(2); + for (const deployment of deployments) { + expect(deployment.litellmParamsObj.input_cost_per_token).toBeCloseTo(0.000003, 12); + expect(deployment.litellmParamsObj.cache_creation_input_token_cost).toBeCloseTo(0.00000375, 12); + expect(deployment.litellmParamsObj.cache_creation_input_token_cost_above_1hr).toBeCloseTo(0.000006, 12); + } + }); + + it("leaves the 1 hour cache write tier unset when its field is blank", async () => { + const formValues = { + model_mappings: [{ public_name: "claude-sonnet", litellm_model: "anthropic/claude-sonnet-4-6" }], + model_name: "anthropic/claude-sonnet-4-6", + custom_pricing: true, + cache_creation_input_token_cost: "3.75", + cache_creation_input_token_cost_above_1hr: "", + }; + + const [deployment] = (await prepareModelAddRequest({ ...formValues }, "token", null))!; + + expect(deployment.litellmParamsObj).not.toHaveProperty("cache_creation_input_token_cost_above_1hr"); + expect(deployment.litellmParamsObj.cache_creation_input_token_cost).toBeCloseTo(0.00000375, 12); + }); + it("uses a lowercase fallback for unrecognized custom providers", async () => { const fallbackValues = { model_mappings: [ diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 5f2747e0415..70b499b108a 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -24,6 +24,65 @@ export const prepareModelAddRequest = async (formValues: Record, ac formValues["model"] = wildcardModel; } + // Handle pricing conversion before processing other fields + // Use explicit checks to allow 0 (zero cost models for budget bypass) + if ( + formValues.input_cost_per_token !== undefined && + formValues.input_cost_per_token !== null && + formValues.input_cost_per_token !== "" + ) { + formValues.input_cost_per_token = Number(formValues.input_cost_per_token) / 1000000; + } + if ( + formValues.output_cost_per_token !== undefined && + formValues.output_cost_per_token !== null && + formValues.output_cost_per_token !== "" + ) { + formValues.output_cost_per_token = Number(formValues.output_cost_per_token) / 1000000; + } + + // Cache Read Cost: if blank, default to Input Cost (already token-unit converted above) + if ( + formValues.cache_read_input_token_cost !== undefined && + formValues.cache_read_input_token_cost !== null && + formValues.cache_read_input_token_cost !== "" + ) { + formValues.cache_read_input_token_cost = Number(formValues.cache_read_input_token_cost) / 1000000; + } else if ( + formValues.input_cost_per_token !== undefined && + formValues.input_cost_per_token !== null && + formValues.input_cost_per_token !== "" + ) { + formValues.cache_read_input_token_cost = Number(formValues.input_cost_per_token); + } else { + delete formValues.cache_read_input_token_cost; + } + + // Cache Write Cost: explicit value if provided, else leave unset so the + // backend keeps the model-level default (per-second pricing, model_prices + // entries, etc.). Sending 0 here would overwrite that default. + // The backend falls back to input_cost_per_token when this key is absent. + if ( + formValues.cache_creation_input_token_cost !== undefined && + formValues.cache_creation_input_token_cost !== null && + formValues.cache_creation_input_token_cost !== "" + ) { + formValues.cache_creation_input_token_cost = Number(formValues.cache_creation_input_token_cost) / 1000000; + } else { + delete formValues.cache_creation_input_token_cost; + } + if ( + formValues.cache_creation_input_token_cost_above_1hr !== undefined && + formValues.cache_creation_input_token_cost_above_1hr !== null && + formValues.cache_creation_input_token_cost_above_1hr !== "" + ) { + formValues.cache_creation_input_token_cost_above_1hr = + Number(formValues.cache_creation_input_token_cost_above_1hr) / 1000000; + } else { + delete formValues.cache_creation_input_token_cost_above_1hr; + } + // Keep input_cost_per_second as is, no conversion needed + // Create a deployment for each mapping const deployments = []; for (const mapping of modelMappings) { @@ -34,55 +93,6 @@ export const prepareModelAddRequest = async (formValues: Record, ac const modelName = mapping.public_name; litellmParamsObj["model"] = mapping.litellm_model; - // Handle pricing conversion before processing other fields - // Use explicit checks to allow 0 (zero cost models for budget bypass) - if ( - formValues.input_cost_per_token !== undefined && - formValues.input_cost_per_token !== null && - formValues.input_cost_per_token !== "" - ) { - formValues.input_cost_per_token = Number(formValues.input_cost_per_token) / 1000000; - } - if ( - formValues.output_cost_per_token !== undefined && - formValues.output_cost_per_token !== null && - formValues.output_cost_per_token !== "" - ) { - formValues.output_cost_per_token = Number(formValues.output_cost_per_token) / 1000000; - } - - // Cache Read Cost: if blank, default to Input Cost (already token-unit converted above) - if ( - formValues.cache_read_input_token_cost !== undefined && - formValues.cache_read_input_token_cost !== null && - formValues.cache_read_input_token_cost !== "" - ) { - formValues.cache_read_input_token_cost = Number(formValues.cache_read_input_token_cost) / 1000000; - } else if ( - formValues.input_cost_per_token !== undefined && - formValues.input_cost_per_token !== null && - formValues.input_cost_per_token !== "" - ) { - formValues.cache_read_input_token_cost = Number(formValues.input_cost_per_token); - } else { - delete formValues.cache_read_input_token_cost; - } - - // Cache Write Cost: explicit value if provided, else leave unset so the - // backend keeps the model-level default (per-second pricing, model_prices - // entries, etc.). Sending 0 here would overwrite that default. - // The backend falls back to input_cost_per_token when this key is absent. - if ( - formValues.cache_creation_input_token_cost !== undefined && - formValues.cache_creation_input_token_cost !== null && - formValues.cache_creation_input_token_cost !== "" - ) { - formValues.cache_creation_input_token_cost = Number(formValues.cache_creation_input_token_cost) / 1000000; - } else { - delete formValues.cache_creation_input_token_cost; - } - // Keep input_cost_per_second as is, no conversion needed - // Iterate through the key-value pairs in formValues litellmParamsObj["model"] = mapping.litellm_model; for (const [key, value] of Object.entries(formValues)) { @@ -155,7 +165,8 @@ export const prepareModelAddRequest = async (formValues: Record, ac key === "output_cost_per_token" || key === "input_cost_per_second" || key === "cache_read_input_token_cost" || - key === "cache_creation_input_token_cost" + key === "cache_creation_input_token_cost" || + key === "cache_creation_input_token_cost_above_1hr" ) { if (value !== undefined && value !== null && value !== "") { litellmParamsObj[key] = Number(value); diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 79717988292..db3e9354350 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1695,13 +1695,54 @@ describe("ModelInfoView", () => { const [cacheRead, cacheWrite] = screen.getAllByPlaceholderText("Defaults to Input Cost if blank"); await user.type(cacheRead, "5"); await user.type(cacheWrite, "9"); + await user.type(screen.getByPlaceholderText("Defaults to the cost map 1 hour rate, else Cache Write Cost"), "15"); const payload = await save(user); - expect(payload.litellm_params).toMatchObject({ + const expectedPricing = { output_cost_per_token: 0.000012, cache_read_input_token_cost: 0.000005, cache_creation_input_token_cost: 0.000009, + cache_creation_input_token_cost_above_1hr: 0.000015, + }; + expect(payload.litellm_params).toMatchObject(expectedPricing); + }); + + describe("1 hour cache write tier", () => { + const withCacheWriteTiers = { + ...defaultModelData, + litellm_params: { + ...defaultModelData.litellm_params, + cache_creation_input_token_cost: 0.00000375, + cache_creation_input_token_cost_above_1hr: 0.000006, + }, + }; + + beforeEach(() => { + mockUseModelsInfo.mockReturnValue({ data: { data: [withCacheWriteTiers] }, isLoading: false, error: null }); + mockModelInfoV1Call.mockResolvedValue({ data: [withCacheWriteTiers] }); + }); + + it("shows both configured cache write rates per million tokens", async () => { + render(, { wrapper }); + + expect(await screen.findByText("Cache Write Cost (1 hour, per 1M tokens)")).toBeInTheDocument(); + expect(screen.getByText("3.7500")).toBeInTheDocument(); + expect(screen.getByText("6.0000")).toBeInTheDocument(); + }); + + it("clearing the 1 hour rate sends an explicit null and keeps the 5 minute rate", async () => { + const user = userEvent.setup(); + await enterEditMode(user); + + const oneHour = screen.getByPlaceholderText("Defaults to the cost map 1 hour rate, else Cache Write Cost"); + expect(oneHour).toHaveValue(6); + await user.clear(oneHour); + + const payload = await save(user); + + expect(payload.litellm_params.cache_creation_input_token_cost_above_1hr).toBeNull(); + expect(payload.litellm_params.cache_creation_input_token_cost).toBe(0.00000375); }); }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 21fad41b2e5..a8df9999f52 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -51,6 +51,9 @@ import { Tag } from "./tag_management/types"; import { getDisplayModelName } from "./view_model/model_name_display"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +const perTokenOrNull = (perMillion: string | number | null | undefined): number | null => + perMillion === undefined || perMillion === null || perMillion === "" ? null : Number(perMillion) / 1_000_000; + interface ModelInfoViewProps { modelId: string; onClose: () => void; @@ -327,19 +330,13 @@ export default function ModelInfoView({ } } - // Cache Write Cost: explicit value if provided, else explicit null so the - // backend removes the override and falls back to the model-level default. - // Sending 0 here would persist a zero rate even when the user intended to unset it. + // Cache write tiers: explicit value if provided, else explicit null so the backend removes the + // override. Sending 0 would persist a zero rate even when the user intended to unset it. if (isFieldTouched("cache_write_cost")) { - if ( - values.cache_write_cost !== undefined && - values.cache_write_cost !== null && - values.cache_write_cost !== "" - ) { - updatedLitellmParams.cache_creation_input_token_cost = Number(values.cache_write_cost) / 1_000_000; - } else { - updatedLitellmParams.cache_creation_input_token_cost = null; - } + updatedLitellmParams.cache_creation_input_token_cost = perTokenOrNull(values.cache_write_cost); + } + if (isFieldTouched("cache_write_cost_1h")) { + updatedLitellmParams.cache_creation_input_token_cost_above_1hr = perTokenOrNull(values.cache_write_cost_1h); } const storedCredentialName: string | null = localModelData?.litellm_params?.litellm_credential_name ?? null; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b77399c963f..bdf936d8bf3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -49929,6 +49929,8 @@ export interface components { blocked?: boolean | null; /** Cache Creation Input Token Cost */ cache_creation_input_token_cost?: number | null; + /** Cache Creation Input Token Cost Above 1Hr */ + cache_creation_input_token_cost_above_1hr?: number | null; /** Cache Read Input Token Cost */ cache_read_input_token_cost?: number | null; /** Cost Per Ptu Per Hour */