diff --git a/litellm-rust/crates/model-catalog/src/wire/attributes.rs b/litellm-rust/crates/model-catalog/src/wire/attributes.rs index 307f25f0bda..d2ba5f609ee 100644 --- a/litellm-rust/crates/model-catalog/src/wire/attributes.rs +++ b/litellm-rust/crates/model-catalog/src/wire/attributes.rs @@ -93,6 +93,8 @@ attributes! { search_free_requests: SearchFreeRequests, /// URL of the provider pricing/model page this entry was taken from. source: String, + /// Model the provider recommends migrating to before `deprecation_date`, as a catalog key when one exists. + successor_model: String, /// Audio container formats the model can return. supported_audio_formats: Vec, /// OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions. @@ -303,6 +305,8 @@ mod tests { #[case::known_mode("mode", json!("chat"), true)] #[case::unknown_mode("mode", json!("chatt"), false)] #[case::negative_dbu("input_dbu_cost_per_token", json!(-1), false)] + #[case::successor_model("successor_model", json!("gpt-6-luna"), true)] + #[case::successor_model_not_a_name("successor_model", json!(["gpt-6-luna"]), false)] #[case::thinking_on_by_default("thinking_on_by_default", json!(true), true)] #[case::thinking_on_by_default_not_a_flag("thinking_on_by_default", json!("yes"), false)] #[case::reasoning_contexts("reasoning_contexts", json!(["reasoning", "non_reasoning"]), true)] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 974f1bb0c8b..5a46a0891fc 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -2135,12 +2135,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.5e-06, + "output_cost_per_token_batches": 1.25e-05 }, "anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2221,12 +2223,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "global.anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.5e-06, + "output_cost_per_token_batches": 1.25e-05 }, "global.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2307,12 +2311,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "us.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2393,12 +2399,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/eu-west-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "eu-west-1" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "eu.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2474,12 +2482,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-southeast-2/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "ap-southeast-4" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "au.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2560,7 +2570,9 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/" + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-northeast-1/index.json", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "jp.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -3949,7 +3961,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true, + "supports_max_reasoning_effort": false, "prompt_cache_min_tokens": 512 }, "azure_ai/claude-fable-5-1": { @@ -3983,10 +3995,40 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true, + "supports_max_reasoning_effort": false, "prompt_cache_min_tokens": 512, "deprecation_date": "2027-12-05" }, + "azure_ai/claude-mythos-5-1": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": false, + "prompt_cache_min_tokens": 512, + "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/claude-models" + }, "azure_ai/claude-opus-5": { "deprecation_date": "2027-07-08", "supports_mid_conversation_system": true, @@ -22269,7 +22311,7 @@ }, "mode": "chat", "output_cost_per_token": 3.74997e-06, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22287,7 +22329,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-7-flash": { "cache_creation_input_token_cost": 7.4998e-07, @@ -22301,7 +22345,7 @@ }, "mode": "chat", "output_cost_per_token": 3.74997e-06, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22319,7 +22363,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-6-flash": { "cache_creation_input_token_cost": 7.4998e-07, @@ -22335,7 +22381,7 @@ "mode": "chat", "output_cost_per_token": 3.74997e-06, "output_dbu_cost_per_token": 5.3571e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22354,7 +22400,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-5-flash": { "cache_creation_input_token_cost": 1.87502e-06, @@ -22370,7 +22418,7 @@ "mode": "chat", "output_cost_per_token": 1.124998e-05, "output_dbu_cost_per_token": 0.000160714, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22388,7 +22436,9 @@ "input_cost_per_token_priority": 3.37498e-06, "output_cost_per_token_priority": 2.025002e-05, "cache_read_input_token_cost_priority": 3.3747e-07, - "cache_creation_input_token_cost_priority": 3.37498e-06 + "cache_creation_input_token_cost_priority": 3.37498e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-5-flash-lite": { "cache_creation_input_token_cost": 3.7499e-07, @@ -22404,7 +22454,7 @@ "mode": "chat", "output_cost_per_token": 3.12501e-06, "output_dbu_cost_per_token": 4.4643e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22424,7 +22474,9 @@ "input_cost_per_token_priority": 6.7501e-07, "output_cost_per_token_priority": 5.62499e-06, "cache_read_input_token_cost_priority": 6.748e-08, - "cache_creation_input_token_cost_priority": 6.7501e-07 + "cache_creation_input_token_cost_priority": 6.7501e-07, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemma-3-12b": { "cache_creation_input_token_cost": 1.5001e-07, @@ -24167,7 +24219,7 @@ "max_output_tokens": 163840, "input_cost_per_token": 5e-07, "output_cost_per_token": 2.15e-06, - "cache_read_input_token_cost": 4e-07, + "cache_read_input_token_cost": 3.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, @@ -24241,7 +24293,7 @@ "max_output_tokens": 163840, "input_cost_per_token": 2.5e-07, "output_cost_per_token": 9.5e-07, - "cache_read_input_token_cost": 2.16e-07, + "cache_read_input_token_cost": 1.3e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, @@ -29639,6 +29691,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-lite-image": { + "deprecation_date": "2027-06-28", "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -30638,6 +30691,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.8-flash": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -30744,6 +30798,7 @@ "supports_native_streaming": true }, "vertex_ai/gemini-3.8-live": { + "deprecation_date": "2027-09-25", "gemini_audio_only_live": true, "input_cost_per_audio_token": 3e-06, "input_cost_per_image_token": 1e-06, @@ -32706,13 +32761,13 @@ "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_input_tokens": 131072, + "max_output_tokens": 57920, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", "supported_endpoints": [ "/v1/chat/completions" ], @@ -32730,7 +32785,8 @@ "supports_reasoning": true, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-11-06" }, "gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -32918,6 +32974,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.8-flash": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -44265,7 +44322,8 @@ "mode": "chat", "supports_function_calling": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/google%2Fgemma-3-27b-it" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 4e-08 }, "nebius/meta-llama/Llama-3.3-70B-Instruct": { "max_input_tokens": 131072, @@ -44491,7 +44549,7 @@ "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash" }, "nebius/deepseek-ai/DeepSeek-V4-Flash-0731": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 1.4e-07, "output_cost_per_token": 2.8e-07, @@ -44499,7 +44557,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash-0731" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-08 }, "nebius/deepseek-ai/DeepSeek-V4-Pro": { "max_input_tokens": 1048576, @@ -44510,29 +44569,32 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Pro" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.4e-07 }, "nebius/deepseek-ai/DeepSeek-V4-Pro-0813": { "input_cost_per_token": 1.32e-06, "litellm_provider": "nebius", - "max_input_tokens": 979000, + "max_input_tokens": 1048576, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Pro-0813", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 4e-08 }, "nebius/deepseek-ai/DeepSeek-V4.1-Flash": { "input_cost_per_token": 3e-07, "litellm_provider": "nebius", - "max_input_tokens": 1048000, + "max_input_tokens": 1048576, "max_output_tokens": 384000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://tokenfactory.nebius.com/endpoints?modals=endpoint-details&model-id=deepseek-ai/DeepSeek-V4.1-Flash", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, "supports_reasoning": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1e-08 }, "nebius/MiniMaxAI/MiniMax-M2.5": { "max_input_tokens": 196608, @@ -44555,7 +44617,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/MiniMaxAI%2FMiniMax-M3" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 6e-08 }, "nebius/moonshotai/Kimi-K2.6": { "max_input_tokens": 262144, @@ -44567,7 +44630,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K2.6" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.6e-07 }, "nebius/moonshotai/Kimi-K2.7-Code": { "max_input_tokens": 262144, @@ -44579,10 +44643,11 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/moonshotai%2FKimi-K2.7-Code" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.5e-07 }, "nebius/moonshotai/Kimi-K3": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, @@ -44591,7 +44656,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K3" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 3e-07 }, "nebius/NousResearch/Hermes-4-405B": { "max_input_tokens": 131072, @@ -44602,7 +44668,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/NousResearch%2FHermes-4-405B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 2e-07 }, "nebius/NousResearch/Hermes-4-70B": { "max_input_tokens": 131072, @@ -44646,7 +44713,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNVIDIA-Nemotron-3-Nano-30B-A3B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-08 }, "nebius/nvidia/Nemotron-3-Nano-Omni": { "max_input_tokens": 262144, @@ -44669,7 +44737,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2Fnemotron-3-super-120b-a12b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 6e-08 }, "nebius/nvidia/Nemotron-3-Ultra-550b-a55b": { "max_input_tokens": 1048576, @@ -44680,7 +44749,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3-Ultra-550b-a55b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-07 }, "nebius/nvidia/Nemotron-3_5-Lightning": { "max_input_tokens": 1048576, @@ -44691,7 +44761,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3_5-Lightning" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 3e-08 }, "nebius/openai/gpt-oss-120b": { "max_input_tokens": 131072, @@ -44702,7 +44773,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/openai%2Fgpt-oss-120b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-07 }, "nebius/openbmb/MiniCPM-V-4_5": { "max_input_tokens": 32000, @@ -44712,7 +44784,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/openbmb%2FMiniCPM-V-4_5" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.3e-07 }, "nebius/Qwen/Qwen3-235B-A22B-Instruct-2507": { "max_input_tokens": 262144, @@ -44722,7 +44795,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-235B-A22B-Instruct-2507" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 5e-08 }, "nebius/Qwen/Qwen3-30B-A3B-Instruct-2507": { "max_input_tokens": 262144, @@ -44732,7 +44806,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-30B-A3B-Instruct-2507" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-07 }, "nebius/Qwen/Qwen3-Next-80B-A3B-Thinking": { "max_input_tokens": 128000, @@ -44754,7 +44829,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3.5-397B-A17B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-07 }, "nebius/Qwen/Qwen3.8-27B": { "input_cost_per_token": 4.5e-07, @@ -44762,9 +44838,10 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3.8-27B", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 1e-07 }, "nebius/zai-org/GLM-5.1": { "max_input_tokens": 202752, @@ -44775,7 +44852,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.1" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 2.6e-07 }, "nebius/zai-org/GLM-5.2": { "max_input_tokens": 1048576, @@ -44786,21 +44864,23 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.2" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.4e-07 }, "nebius/zai-org/GLM-5.3": { "input_cost_per_token": 1.4e-06, "litellm_provider": "nebius", - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.3", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 2.6e-07 }, "nebius/zai-org/GLM-5.3-Flash": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 1.5e-07, "output_cost_per_token": 5e-07, @@ -44808,8 +44888,9 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.3-Flash", - "supports_vision": true + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "supports_vision": true, + "cache_read_input_token_cost": 3e-08 }, "nebius/BAAI/bge-en-icl": { "max_input_tokens": 32768, @@ -49813,7 +49894,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-west-1/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -49835,7 +49916,9 @@ "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-gov-west-1" - } + }, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05 }, "us-gov.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -52922,6 +53005,7 @@ }, "vertex_ai/gemini-3.1-flash-lite-image": { + "deprecation_date": "2027-06-28", "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -54150,7 +54234,7 @@ "voyage/voyage-lite-01": { "input_cost_per_token": 1e-07, "litellm_provider": "voyage", - "max_input_tokens": 4096, + "max_input_tokens": 4000, "mode": "embedding", "output_cost_per_token": 0.0 }, @@ -64809,7 +64893,7 @@ "supports_vision": false }, "novita/deepseek/deepseek-v4-pro-0813": { - "cache_read_input_token_cost": 1.3200000000000002e-07, + "cache_read_input_token_cost": 4.4e-08, "input_cost_per_token": 1.32e-06, "litellm_provider": "novita", "max_input_tokens": 1048576, @@ -64921,13 +65005,13 @@ "supports_vision": false }, "novita/mindai/macaron-v1-venti": { - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 1.5e-06, + "cache_read_input_token_cost": 1.65e-07, + "input_cost_per_token": 8.25e-07, "litellm_provider": "novita", "max_input_tokens": 1048576, "max_output_tokens": 131072, "mode": "chat", - "output_cost_per_token": 4.5e-06, + "output_cost_per_token": 2.475e-06, "source": "https://novita.ai/pricing", "supports_function_calling": true, "supports_prompt_caching": true, @@ -65030,13 +65114,13 @@ "supports_vision": false }, "novita/mindai/macaron-v1-tall": { - "cache_read_input_token_cost": 8e-08, - "input_cost_per_token": 4.5000000000000003e-07, + "cache_read_input_token_cost": 4.4e-08, + "input_cost_per_token": 2.475e-07, "litellm_provider": "novita", "max_input_tokens": 262144, "max_output_tokens": 32768, "mode": "chat", - "output_cost_per_token": 2.6e-06, + "output_cost_per_token": 1.43e-06, "source": "https://novita.ai/pricing", "supports_function_calling": true, "supports_prompt_caching": true, @@ -66461,9 +66545,9 @@ "deepinfra/deepseek-ai/DeepSeek-V4-Flash-0731": { "max_output_tokens": 1048576, "max_input_tokens": 1048576, - "input_cost_per_token": 8e-08, + "input_cost_per_token": 6e-08, "output_cost_per_token": 1.8e-07, - "cache_read_input_token_cost": 1.6e-08, + "cache_read_input_token_cost": 1.5e-08, "supports_prompt_caching": true, "litellm_provider": "deepinfra", "mode": "chat", @@ -66842,8 +66926,8 @@ "deepinfra/google/gemma-4-31B-it": { "max_output_tokens": 262144, "max_input_tokens": 262144, - "input_cost_per_token": 1.3e-07, - "output_cost_per_token": 3.8e-07, + "input_cost_per_token": 2e-07, + "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_function_calling": true, @@ -68554,7 +68638,10 @@ "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-gov-west-1" - } + }, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-west-1/index.json" }, "bedrock/us-gov-west-1/anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -68829,7 +68916,10 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-east-1/index.json" }, "bedrock/us-gov-east-1/anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -69852,6 +69942,14 @@ "cache_read_input_token_cost": 3e-08, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, + "perplexity/google/gemini-4-argon": { + "litellm_provider": "perplexity", + "mode": "responses", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1e-07, + "source": "https://docs.perplexity.ai/docs/agent-api/models" + }, "perplexity/google/gemini-3.6-flash": { "litellm_provider": "perplexity", "mode": "responses", @@ -69979,6 +70077,14 @@ "cache_read_input_token_cost": 1.15e-09, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, + "perplexity/perplexity/nemotron-3.5-super-120b-a12b": { + "litellm_provider": "perplexity", + "mode": "responses", + "input_cost_per_token": 2e-07, + "output_cost_per_token": 5e-07, + "cache_read_input_token_cost": 4e-08, + "source": "https://docs.perplexity.ai/docs/agent-api/models" + }, "perplexity/perplexity/nemotron-3-ultra-550b-a55b": { "litellm_provider": "perplexity", "mode": "responses", @@ -70104,12 +70210,12 @@ "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai", "max_output_tokens": 57920, - "max_input_tokens": 57920, + "max_input_tokens": 131072, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-1-1-flash", "supported_endpoints": [ "/v1beta/interactions" ], @@ -74505,12 +74611,14 @@ "input_cost_per_token": 5.5e-06, "cache_read_input_token_cost": 5.5e-07, "output_cost_per_token": 2.75e-05, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-northeast-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "ap-southeast-4" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "apac.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -75107,7 +75215,7 @@ "supports_function_calling": true, "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, - "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost": 1e-07, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "perplexity/anthropic/claude-fable-5-1": { @@ -75155,6 +75263,9 @@ "output_cost_per_token": 5e-05, "cache_read_input_token_cost": 1e-06, "cache_creation_input_token_cost": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "perplexity/openai/gpt-6-luna": { @@ -76622,6 +76733,9 @@ "input_cost_per_token": 1e-07, "output_cost_per_token": 5e-07, "cache_read_input_token_cost": 1e-08, + "input_cost_per_token_above_100k_tokens": 5e-07, + "output_cost_per_token_above_100k_tokens": 2.5e-06, + "cache_read_input_token_cost_above_100k_tokens": 5e-08, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "us-gov.anthropic.claude-haiku-5-5": { @@ -77096,6 +77210,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-4-argon": { + "deprecation_date": "2027-10-13", "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, @@ -77504,9 +77619,10 @@ "max_output_tokens": 1048576, "mode": "chat", "output_cost_per_token": 1e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/reflection%2Fbeam", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 5e-08 }, "baseten/deepseek-ai/DeepSeek-V4.1-Flash-1017": { "cache_read_input_token_cost": 7e-09, @@ -81777,5 +81893,55 @@ "supports_response_schema": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true + }, + "nebius/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 6e-09, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/meta-models/muse-spark-open": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.25e-06, + "cache_read_input_token_cost": 1.5e-07, + "output_cost_per_token": 4.25e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_video_input": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/MiniMaxAI/MiniMax-M3.1-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.5e-07, + "cache_read_input_token_cost": 1.5e-08, + "output_cost_per_token": 6e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/nvidia/Nemotron-3.5-Super-VL": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 6e-08, + "output_cost_per_token": 9e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_video_input": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" } } diff --git a/litellm/proxy/spend_tracking/session_page.py b/litellm/proxy/spend_tracking/session_page.py new file mode 100644 index 00000000000..810afbfa7b7 --- /dev/null +++ b/litellm/proxy/spend_tracking/session_page.py @@ -0,0 +1,221 @@ +"""Which sessions a logs page shows and how many match, read from the newest rows instead of every row. Reads grow +until one settles both, so the result is what grouping every row would give.""" + +import math +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import Final, Protocol, TypeAlias + +from typing_extensions import ReadOnly, TypedDict + +MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')" +AGENT_CALL_TYPE_SQL: Final = "'asend_message'" +SESSION_KEY_EXPR: Final = "COALESCE(NULLIF(session_id, ''), request_id)" +SESSION_REPRESENTATIVE_ORDER_SQL: Final = ( + f"(call_type = {AGENT_CALL_TYPE_SQL}) DESC, " + f'CASE WHEN call_type = {AGENT_CALL_TYPE_SQL} THEN "endTime" END DESC NULLS LAST, ' + f'call_type IN {MCP_CALL_TYPES_SQL}, "startTime" DESC, request_id' +) + + +class _ReadRow(TypedDict): + session_key: ReadOnly[str | None] + api_key: ReadOnly[str | None] + last_activity: ReadOnly[str | None] + settled: ReadOnly[bool | None] + rows_read: ReadOnly[int] + session_count: ReadOnly[int] + + +class _SupportsQueryRaw(Protocol): + async def query_raw(self, query: str, *args: object) -> Sequence[_ReadRow]: ... + + +@dataclass(frozen=True, slots=True) +class Session: + key: str + api_key: str + last_activity: str + + +@dataclass(frozen=True, slots=True) +class SessionPage: + sessions: tuple[Session, ...] + has_more: bool + next_cursor: str | None + total: int + total_is_capped: bool + + +@dataclass(frozen=True, slots=True) +class _Request: + cursor: tuple[str, str, str] | None + offset: int + page_limit: int + sort_desc: bool + status: str | None + cap: int + + def first_read_size(self) -> int | None: + """Every row when oldest first, or when filtered by outcome, which any of a session's rows can decide. Otherwise + the fewest that could settle a page: a session past the cap and an older row.""" + return self.cap + 2 if self.sort_desc and self.status is None else None + + +@dataclass(frozen=True, slots=True) +class _Read: + candidates: tuple[Session, ...] + last_settled: bool + rows_read: int + session_count: int + saw_every_row: bool + + def settles(self, request: _Request) -> bool: + page_settled: Final = ( + request.page_limit <= 0 + or self.saw_every_row + or (len(self.candidates) > request.page_limit and self.last_settled) + ) + return page_settled and (self.saw_every_row or self.session_count > request.cap) + + def next_size(self, request: _Request) -> int: + rows_per_session: Final = self.rows_read / self.session_count + return max(2 * self.rows_read, math.ceil(1.25 * (request.cap + 1) * rows_per_session)) + + +_Reader: TypeAlias = Callable[[int | None], Awaitable[_Read]] + + +def _parse_session_cursor(session_cursor: str | None) -> tuple[str, str, str] | None: + if session_cursor is None or session_cursor.count("|") < 2: + return None + last_activity, _, rest = session_cursor.partition("|") + api_key, _, session_key = rest.partition("|") + if not last_activity or not session_key: + return None + return (last_activity, session_key, api_key) + + +async def read_session_page( + db: _SupportsQueryRaw, + *, + where_clause: str, + params: Sequence[object], + status: str | None, + page: int, + page_size: int, + sort_desc: bool, + session_cursor: str | None, + cap: int, +) -> SessionPage: + """One page of sessions after ``session_cursor``, or after ``(page - 1) * page_size`` sessions, keeping only those + whose outcome is ``status`` when given. ``where_clause`` reads ``params`` as ``$1`` onward.""" + cursor: Final = _parse_session_cursor(session_cursor) + offset: Final = (page - 1) * page_size if cursor is None else 0 + request: Final = _Request(cursor, offset, min(page_size, cap - offset), sort_desc, status, cap) + return await _session_page(_sql_reader(db, where_clause, params, request), request) + + +async def _session_page(read: _Reader, request: _Request) -> SessionPage: + last = await read(request.first_read_size()) # rebind-ok: each read replaces the last until one settles the page + while not last.settles(request): + last = await read(last.next_size(request)) + has_more: Final = len(last.candidates) > request.page_limit + sessions: Final = last.candidates[: request.page_limit] + final: Final = sessions[-1] if has_more and sessions else None + return SessionPage( + sessions=sessions, + has_more=has_more, + next_cursor=f"{final.last_activity}|{final.api_key}|{final.key}" if final else None, + total=min(last.session_count, request.cap), + total_is_capped=last.session_count > request.cap, + ) + + +def _sessions_sql(request: _Request, status_index: int) -> str: + """Each session's last activity, from its rows in ``scanned``, keeping only sessions whose outcome is the status.""" + if request.status is None: + return ( + 'SELECT session_key, api_key, MAX("startTime") AS last_activity FROM scanned GROUP BY session_key, api_key' + ) + return f""" + SELECT session_key, api_key, last_activity + FROM ( + SELECT DISTINCT ON (session_key, api_key) + session_key, + api_key, + status, + MAX("startTime") OVER (PARTITION BY session_key, api_key) AS last_activity + FROM scanned + ORDER BY session_key, api_key, {SESSION_REPRESENTATIVE_ORDER_SQL} + ) AS outcomes + WHERE COALESCE(status, 'success') = ${status_index} + """ + + +def _sql_reader(db: _SupportsQueryRaw, where_clause: str, params: Sequence[object], request: _Request) -> _Reader: + status_index: Final = len(params) + 1 + status_args: Final = () if request.status is None else (request.status,) + cursor_index: Final = status_index + len(status_args) + after_cursor: Final = ( + f"WHERE (last_activity, session_key, api_key) {'<' if request.sort_desc else '>'} " + f"(${cursor_index}::timestamp, ${cursor_index + 1}, ${cursor_index + 2})" + if request.cursor + else "" + ) + outcome_columns: Final = ', status, call_type, "endTime", request_id' if request.status is not None else "" + direction: Final = "DESC" if request.sort_desc else "ASC" + page_rows: Final = request.page_limit + 1 if request.page_limit > 0 else 0 + skip: Final = f"OFFSET {request.offset}" if page_rows and request.offset else "" + + async def read(size: int | None) -> _Read: + # LEFT JOIN keeps the summary row when no session is on the page. + rows: Final = await db.query_raw( + f""" + WITH scanned AS ( + SELECT {SESSION_KEY_EXPR} AS session_key, api_key, "startTime"{outcome_columns} + FROM "LiteLLM_SpendLogs" + WHERE {where_clause} + {f'ORDER BY "startTime" DESC LIMIT {size}' if size is not None else ""} + ), + sessions AS ({_sessions_sql(request, status_index)}), + summary AS ( + SELECT COUNT(*) AS rows_read, + MIN("startTime") AS oldest, + (SELECT COUNT(*) FROM sessions) AS session_count + FROM scanned + ) + SELECT page.session_key, + page.api_key, + page.last_activity::text AS last_activity, + page.last_activity > summary.oldest AS settled, + summary.rows_read, + summary.session_count + FROM summary + LEFT JOIN LATERAL ( + SELECT session_key, api_key, last_activity + FROM sessions + {after_cursor} + ORDER BY last_activity {direction}, session_key {direction}, api_key {direction} + LIMIT {page_rows} {skip} + ) AS page ON TRUE + ORDER BY page.last_activity {direction}, page.session_key {direction}, page.api_key {direction} + """, + *params, + *status_args, + *(request.cursor or ()), + ) + rows_read: Final = int(rows[0]["rows_read"]) + return _Read( + candidates=tuple( + Session(str(row["session_key"]), str(row["api_key"]), str(row["last_activity"])) + for row in rows + if row["session_key"] is not None + ), + last_settled=bool(rows[-1]["settled"]), + rows_read=rows_read, + session_count=int(rows[0]["session_count"]), + saw_every_row=size is None or rows_read < size, + ) + + return read diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 89200792c0b..fcd719b24b9 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -52,6 +52,13 @@ from litellm.proxy.auth.authorization_dependencies import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.openai_error_payload import error_status_code, openai_error_param, openai_error_type from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.spend_tracking.session_page import ( + AGENT_CALL_TYPE_SQL, + MCP_CALL_TYPES_SQL, + SESSION_KEY_EXPR, + SESSION_REPRESENTATIVE_ORDER_SQL, + read_session_page, +) from litellm.proxy.spend_tracking.spend_capture_rate import ( ProviderBillingCredentialMissing, ProviderBillingRequestFailed, @@ -87,23 +94,15 @@ router: Final = APIRouter() SPEND_LOGS_PAGINATION_COUNT_CAP: Final = 10000 _DECODED_JSON: Final = TypeAdapter(object) -_SESSION_KEY_EXPR: Final = "COALESCE(NULLIF(session_id, ''), request_id)" -_SESSION_GROUP_KEY_SQL: Final = f"{_SESSION_KEY_EXPR}, api_key" -_MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')" -_AGENT_CALL_TYPE_SQL: Final = "'asend_message'" -_SESSION_REPRESENTATIVE_ORDER_SQL: Final = ( - f"(call_type = {_AGENT_CALL_TYPE_SQL}) DESC, " - f'CASE WHEN call_type = {_AGENT_CALL_TYPE_SQL} THEN "endTime" END DESC NULLS LAST, ' - f'call_type IN {_MCP_CALL_TYPES_SQL}, "startTime" DESC, request_id' -) +_SESSION_GROUP_KEY_SQL: Final = f"{SESSION_KEY_EXPR}, api_key" _BATCH_CALL_TYPES_SQL: Final = "('acreate_batch', 'create_batch', 'aretrieve_batch', 'retrieve_batch')" _SPAN_TYPE_SQL_CONDITIONS: Final[Mapping[str, str]] = MappingProxyType( { - "mcp": f"call_type IN {_MCP_CALL_TYPES_SQL}", - "agent": f"call_type = {_AGENT_CALL_TYPE_SQL}", + "mcp": f"call_type IN {MCP_CALL_TYPES_SQL}", + "agent": f"call_type = {AGENT_CALL_TYPE_SQL}", "batch": f"call_type IN {_BATCH_CALL_TYPES_SQL}", "llm": ( - f"(call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} " + f"(call_type NOT IN {MCP_CALL_TYPES_SQL} AND call_type != {AGENT_CALL_TYPE_SQL} " f"AND call_type NOT IN {_BATCH_CALL_TYPES_SQL})" ), } @@ -2948,23 +2947,6 @@ async def ui_view_spend_logs( sql_params.append(json.dumps(used_client_oauth_token)) p += 1 - if status_filter is not None and group_by_session is True and not is_search_lookup: - session_filter_conditions: Final = " AND ".join(sql_conditions) or "TRUE" - sql_conditions.append( - f"""({_SESSION_GROUP_KEY_SQL}) IN ( - SELECT session_key, api_key FROM ( - SELECT DISTINCT ON ({_SESSION_GROUP_KEY_SQL}) - {_SESSION_KEY_EXPR} AS session_key, api_key, status - FROM "LiteLLM_SpendLogs" - WHERE {session_filter_conditions} - ORDER BY {_SESSION_GROUP_KEY_SQL}, {_SESSION_REPRESENTATIVE_ORDER_SQL} - ) AS session_outcomes - WHERE COALESCE(status, 'success') = ${p} - )""" - ) - sql_params.append(status_filter) - p += 1 - if ( group_by_session is True and not is_v2 @@ -2977,12 +2959,30 @@ async def ui_view_spend_logs( sql_conditions=sql_conditions, sql_params=sql_params, next_param_index=p, + status_filter=status_filter, page=page, page_size=page_size, sort_desc=order_direction != "asc", session_cursor=session_cursor, ) + if status_filter is not None and group_by_session is True and not is_search_lookup: + session_filter_conditions: Final = " AND ".join(sql_conditions) or "TRUE" + sql_conditions.append( + f"""({_SESSION_GROUP_KEY_SQL}) IN ( + SELECT session_key, api_key FROM ( + SELECT DISTINCT ON ({_SESSION_GROUP_KEY_SQL}) + {SESSION_KEY_EXPR} AS session_key, api_key, status + FROM "LiteLLM_SpendLogs" + WHERE {session_filter_conditions} + ORDER BY {_SESSION_GROUP_KEY_SQL}, {SESSION_REPRESENTATIVE_ORDER_SQL} + ) AS session_outcomes + WHERE COALESCE(status, 'success') = ${p} + )""" + ) + sql_params.append(status_filter) + p += 1 + # Build the ORDER BY expression. ttft_ms is computed from # completionStartTime - startTime; non-streaming rows (where # completionStartTime is null or equals endTime) yield NULL, so we @@ -3031,7 +3031,7 @@ async def ui_view_spend_logs( {_SPEND_LOG_LIST_COLUMNS} FROM "LiteLLM_SpendLogs" WHERE {joined_conditions} - ORDER BY {_SESSION_GROUP_KEY_SQL}, {_SESSION_REPRESENTATIVE_ORDER_SQL} + ORDER BY {_SESSION_GROUP_KEY_SQL}, {SESSION_REPRESENTATIVE_ORDER_SQL} ) AS session_representatives ORDER BY {exact_request_id_first}{_order_expr} {_sql_dir}{_nulls_clause}, request_id LIMIT ${p} OFFSET ${p + 1} @@ -3080,22 +3080,6 @@ async def ui_view_spend_logs( raise handle_exception_on_proxy(e) -class _SessionPageRow(TypedDict): - session_key: ReadOnly[str] - api_key: ReadOnly[str] - last_activity: ReadOnly[str] - - -def _parse_session_cursor(session_cursor: str | None) -> tuple[str, str, str] | None: - if session_cursor is None or session_cursor.count("|") < 2: - return None - last_activity, _, rest = session_cursor.partition("|") - api_key, _, session_key = rest.partition("|") - if not last_activity or not session_key: - return None - return (last_activity, session_key, api_key) - - async def _fetch_session_representatives( prisma_client: "PrismaClient", where_clause: str, @@ -3104,16 +3088,18 @@ async def _fetch_session_representatives( session_keys: Sequence[tuple[str, str]], ) -> list[dict[str, object]]: # mutable-ok: _build_ui_spend_logs_response writes session counts onto each row """Fetch the final agent outcome, or newest non-MCP row, of each ``(session_key, api_key)`` session, in ``session_keys`` order.""" + # Lets Postgres use the session_id and request_id indexes. rep_query: Final = f""" SELECT * FROM ( SELECT DISTINCT ON ({_SESSION_GROUP_KEY_SQL}) {_SPEND_LOG_LIST_COLUMNS} FROM "LiteLLM_SpendLogs" WHERE {where_clause} + AND (session_id = ANY(${next_param_index}::text[]) OR request_id = ANY(${next_param_index}::text[])) AND ({_SESSION_GROUP_KEY_SQL}) IN ( SELECT * FROM unnest(${next_param_index}::text[], ${next_param_index + 1}::text[]) ) - ORDER BY {_SESSION_GROUP_KEY_SQL}, {_SESSION_REPRESENTATIVE_ORDER_SQL} + ORDER BY {_SESSION_GROUP_KEY_SQL}, {SESSION_REPRESENTATIVE_ORDER_SQL} ) AS session_representatives """ rep_rows: Final[Sequence[dict[str, object]]] = await _query_raw( # mutable-ok: rows are enriched in place @@ -3129,117 +3115,32 @@ async def _fetch_session_representatives( return [rep_by_key[key] for key in session_keys if key in rep_by_key] -async def _count_grouped_sessions( - prisma_client: "PrismaClient", - where_clause: str, - sql_params: Sequence[object], - next_param_index: int, -) -> tuple[int, bool]: - """Count the sessions matching the filter, returning ``(total, total_is_capped)`` bounded by the count cap.""" - count_query: Final = f""" - SELECT COUNT(*) AS total_count - FROM ( - SELECT 1 - FROM "LiteLLM_SpendLogs" - WHERE {where_clause} - GROUP BY {_SESSION_GROUP_KEY_SQL} - LIMIT ${next_param_index} - ) AS bounded_sessions - """ - count_rows: Final[Sequence[_SpendLogsCountRow]] = await _query_raw( - prisma_client, count_query, *sql_params, SPEND_LOGS_PAGINATION_COUNT_CAP + 1 - ) - raw_total: Final = int(count_rows[0]["total_count"]) if count_rows else 0 - return ( - (SPEND_LOGS_PAGINATION_COUNT_CAP, True) if raw_total > SPEND_LOGS_PAGINATION_COUNT_CAP else (raw_total, False) - ) - - async def _ui_session_grouped_spend_logs( prisma_client: "PrismaClient", sql_conditions: Sequence[str], sql_params: Sequence[object], next_param_index: int, + status_filter: str | None, page: int, page_size: int, sort_desc: bool, session_cursor: str | None, ) -> Mapping[str, object]: - """ - One row per session, keyset-paginated by session last activity. - - Sessions are derived on the fly from ``LiteLLM_SpendLogs`` (no extra - table): rows sharing a ``session_id`` and ``api_key`` form a session, rows - without a session id are singletons keyed by ``request_id``. A page is the - next ``page_size`` sessions ordered by ``(MAX(startTime), session_key, - api_key)``, resumed from the ``session_cursor`` keyset - ``'||'`` instead of an OFFSET, so - page depth does not degrade the query plan. A request for ``page > 1`` - without a cursor (the UI jumping straight to the last page, or back to a - page it never walked through) falls back to ``OFFSET (page - 1) * - page_size``, trimmed to the end of the ``SPEND_LOGS_PAGINATION_COUNT_CAP`` - window the capped ``total`` promises, so a page never runs past that total - and one starting at or past it returns no rows without a query. Each session is represented - by its final agent outcome (or newest non-MCP row), enriched by ``_build_ui_spend_logs_response`` - exactly like the flat listing, and the response carries - ``next_session_cursor`` / ``has_more`` while ``total`` counts sessions - (capped like the flat total). A page that runs out of sessions while still - holding some is itself the end of the list, so its ``total`` is - ``offset + len(page)`` and the grouped count query is skipped; a page that - starts past the end says nothing about the total, so that one is counted. - """ + """One row per session, keyset-paginated by last activity. ``read_session_page`` picks and counts the sessions, + and each is shown by its final agent outcome or newest non-MCP row.""" where_clause: Final = " AND ".join(sql_conditions) if sql_conditions else "TRUE" - cmp_op: Final = "<" if sort_desc else ">" - direction: Final = "DESC" if sort_desc else "ASC" - - cursor: Final = _parse_session_cursor(session_cursor) - having_clause: Final = ( - f'HAVING (MAX("startTime"), {_SESSION_GROUP_KEY_SQL}) {cmp_op} ' - f"(${next_param_index}::timestamp, ${next_param_index + 1}, ${next_param_index + 2})" - if cursor - else "" + session_page: Final = await read_session_page( + prisma_client.db, # pyright: ignore[reportArgumentType] # PrismaWrapper forwards raw queries via __getattr__ + where_clause=where_clause, + params=sql_params, + status=status_filter, + page=page, + page_size=page_size, + sort_desc=sort_desc, + session_cursor=session_cursor, + cap=SPEND_LOGS_PAGINATION_COUNT_CAP, ) - cursor_params: Final[tuple[object, ...]] = cursor if cursor else () - limit_index: Final = next_param_index + len(cursor_params) - offset: Final = (page - 1) * page_size if cursor is None else 0 - page_limit: Final = min(page_size, SPEND_LOGS_PAGINATION_COUNT_CAP - offset) - offset_params: Final[tuple[int, ...]] = (offset,) if offset and page_limit > 0 else () - offset_clause: Final = f"OFFSET ${limit_index + 1}" if offset_params else "" - - page_query: Final = f""" - SELECT {_SESSION_KEY_EXPR} AS session_key, - api_key, - MAX("startTime")::text AS last_activity - FROM "LiteLLM_SpendLogs" - WHERE {where_clause} - GROUP BY {_SESSION_GROUP_KEY_SQL} - {having_clause} - ORDER BY MAX("startTime") {direction}, {_SESSION_KEY_EXPR} {direction}, api_key {direction} - LIMIT ${limit_index} {offset_clause} - """ - page_rows: Final[Sequence[_SessionPageRow]] = ( - () - if page_limit <= 0 - else await _query_raw(prisma_client, page_query, *sql_params, *cursor_params, page_limit + 1, *offset_params) - ) - - has_more: Final = len(page_rows) > page_limit - visible_rows: Final = page_rows[:page_limit] - next_cursor: Final = ( - f"{visible_rows[-1]['last_activity']}|{visible_rows[-1]['api_key']}|{visible_rows[-1]['session_key']}" - if has_more and visible_rows - else None - ) - - page_starts_inside_the_list: Final = offset == 0 or len(page_rows) > 0 - page_ends_the_list: Final = cursor is None and page_limit > 0 and not has_more and page_starts_inside_the_list - total_records, total_is_capped = ( - (offset + len(page_rows), False) - if page_ends_the_list - else await _count_grouped_sessions(prisma_client, where_clause, sql_params, next_param_index) - ) - - session_keys: Final = tuple((row["session_key"], row["api_key"]) for row in visible_rows) + session_keys: Final = tuple((session.key, session.api_key) for session in session_page.sessions) data: Final[list[dict[str, object]]] = ( # mutable-ok: _build_ui_spend_logs_response writes onto each row await _fetch_session_representatives( prisma_client=prisma_client, @@ -3253,18 +3154,17 @@ async def _ui_session_grouped_spend_logs( ) _hydrate_spend_log_metadata(data) - total_pages: Final = (total_records + page_size - 1) // page_size response: Final[Mapping[str, object]] = await _build_ui_spend_logs_response( prisma_client, data, - total_records, + session_page.total, page, page_size, - total_pages, + (session_page.total + page_size - 1) // page_size, enrich_session_counts=True, - total_is_capped=total_is_capped, + total_is_capped=session_page.total_is_capped, ) - return {**response, "next_session_cursor": next_cursor, "has_more": has_more} + return {**response, "next_session_cursor": session_page.next_cursor, "has_more": session_page.has_more} class RequestResponsePayload(NamedTuple): @@ -4720,16 +4620,16 @@ async def _build_ui_spend_logs_response( ) ), 0)::bigint AS session_total_duration_ms, COUNT(*) FILTER ( - WHERE call_type IN {_MCP_CALL_TYPES_SQL} + WHERE call_type IN {MCP_CALL_TYPES_SQL} )::int AS mcp_tool_call_count, COALESCE(SUM(spend) FILTER ( - WHERE call_type IN {_MCP_CALL_TYPES_SQL} + WHERE call_type IN {MCP_CALL_TYPES_SQL} ), 0)::double precision AS mcp_tool_call_spend, COUNT(*) FILTER (WHERE LOWER(cache_hit) = 'true')::int AS session_cache_hit_count, COUNT(*) FILTER ( - WHERE call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} + WHERE call_type NOT IN {MCP_CALL_TYPES_SQL} AND call_type != {AGENT_CALL_TYPE_SQL} )::int AS session_llm_count, - COUNT(*) FILTER (WHERE call_type = {_AGENT_CALL_TYPE_SQL})::int AS session_agent_count, + COUNT(*) FILTER (WHERE call_type = {AGENT_CALL_TYPE_SQL})::int AS session_agent_count, COALESCE(SUM(prompt_tokens), 0)::bigint AS session_total_prompt_tokens, COALESCE(SUM(completion_tokens), 0)::bigint AS session_total_completion_tokens, COALESCE(SUM(total_tokens), 0)::bigint AS session_total_tokens diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 974f1bb0c8b..5a46a0891fc 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -2135,12 +2135,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.5e-06, + "output_cost_per_token_batches": 1.25e-05 }, "anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2221,12 +2223,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "global.anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.5e-06, + "output_cost_per_token_batches": 1.25e-05 }, "global.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2307,12 +2311,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-east-1" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "us.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2393,12 +2399,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/eu-west-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "eu-west-1" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "eu.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2474,12 +2482,14 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-southeast-2/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "ap-southeast-4" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "au.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -2560,7 +2570,9 @@ "supports_output_config": true, "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/" + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-northeast-1/index.json", + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "jp.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -3949,7 +3961,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true, + "supports_max_reasoning_effort": false, "prompt_cache_min_tokens": 512 }, "azure_ai/claude-fable-5-1": { @@ -3983,10 +3995,40 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true, + "supports_max_reasoning_effort": false, "prompt_cache_min_tokens": 512, "deprecation_date": "2027-12-05" }, + "azure_ai/claude-mythos-5-1": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": false, + "prompt_cache_min_tokens": 512, + "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/claude-models" + }, "azure_ai/claude-opus-5": { "deprecation_date": "2027-07-08", "supports_mid_conversation_system": true, @@ -22269,7 +22311,7 @@ }, "mode": "chat", "output_cost_per_token": 3.74997e-06, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22287,7 +22329,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-7-flash": { "cache_creation_input_token_cost": 7.4998e-07, @@ -22301,7 +22345,7 @@ }, "mode": "chat", "output_cost_per_token": 3.74997e-06, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22319,7 +22363,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-6-flash": { "cache_creation_input_token_cost": 7.4998e-07, @@ -22335,7 +22381,7 @@ "mode": "chat", "output_cost_per_token": 3.74997e-06, "output_dbu_cost_per_token": 5.3571e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22354,7 +22400,9 @@ "input_cost_per_token_priority": 1.35002e-06, "output_cost_per_token_priority": 6.75003e-06, "cache_read_input_token_cost_priority": 1.3503e-07, - "cache_creation_input_token_cost_priority": 1.35002e-06 + "cache_creation_input_token_cost_priority": 1.35002e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-5-flash": { "cache_creation_input_token_cost": 1.87502e-06, @@ -22370,7 +22418,7 @@ "mode": "chat", "output_cost_per_token": 1.124998e-05, "output_dbu_cost_per_token": 0.000160714, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22388,7 +22436,9 @@ "input_cost_per_token_priority": 3.37498e-06, "output_cost_per_token_priority": 2.025002e-05, "cache_read_input_token_cost_priority": 3.3747e-07, - "cache_creation_input_token_cost_priority": 3.37498e-06 + "cache_creation_input_token_cost_priority": 3.37498e-06, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemini-3-5-flash-lite": { "cache_creation_input_token_cost": 3.7499e-07, @@ -22404,7 +22454,7 @@ "mode": "chat", "output_cost_per_token": 3.12501e-06, "output_dbu_cost_per_token": 4.4643e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "source": "https://docs.databricks.com/aws/en/machine-learning/foundation-model-apis/supported-models", "supported_modalities": [ "text", "image", @@ -22424,7 +22474,9 @@ "input_cost_per_token_priority": 6.7501e-07, "output_cost_per_token_priority": 5.62499e-06, "cache_read_input_token_cost_priority": 6.748e-08, - "cache_creation_input_token_cost_priority": 6.7501e-07 + "cache_creation_input_token_cost_priority": 6.7501e-07, + "supports_audio_input": true, + "supports_video_input": true }, "databricks/databricks-gemma-3-12b": { "cache_creation_input_token_cost": 1.5001e-07, @@ -24167,7 +24219,7 @@ "max_output_tokens": 163840, "input_cost_per_token": 5e-07, "output_cost_per_token": 2.15e-06, - "cache_read_input_token_cost": 4e-07, + "cache_read_input_token_cost": 3.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, @@ -24241,7 +24293,7 @@ "max_output_tokens": 163840, "input_cost_per_token": 2.5e-07, "output_cost_per_token": 9.5e-07, - "cache_read_input_token_cost": 2.16e-07, + "cache_read_input_token_cost": 1.3e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, @@ -29639,6 +29691,7 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-flash-lite-image": { + "deprecation_date": "2027-06-28", "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -30638,6 +30691,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.8-flash": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -30744,6 +30798,7 @@ "supports_native_streaming": true }, "vertex_ai/gemini-3.8-live": { + "deprecation_date": "2027-09-25", "gemini_audio_only_live": true, "input_cost_per_audio_token": 3e-06, "input_cost_per_image_token": 1e-06, @@ -32706,13 +32761,13 @@ "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_input_tokens": 131072, + "max_output_tokens": 57920, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", "supported_endpoints": [ "/v1/chat/completions" ], @@ -32730,7 +32785,8 @@ "supports_reasoning": true, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-11-06" }, "gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -32918,6 +32974,7 @@ "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.8-flash": { + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -44265,7 +44322,8 @@ "mode": "chat", "supports_function_calling": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/google%2Fgemma-3-27b-it" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 4e-08 }, "nebius/meta-llama/Llama-3.3-70B-Instruct": { "max_input_tokens": 131072, @@ -44491,7 +44549,7 @@ "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash" }, "nebius/deepseek-ai/DeepSeek-V4-Flash-0731": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 1.4e-07, "output_cost_per_token": 2.8e-07, @@ -44499,7 +44557,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Flash-0731" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-08 }, "nebius/deepseek-ai/DeepSeek-V4-Pro": { "max_input_tokens": 1048576, @@ -44510,29 +44569,32 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Pro" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.4e-07 }, "nebius/deepseek-ai/DeepSeek-V4-Pro-0813": { "input_cost_per_token": 1.32e-06, "litellm_provider": "nebius", - "max_input_tokens": 979000, + "max_input_tokens": 1048576, "mode": "chat", "output_cost_per_token": 3.96e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/deepseek-ai%2FDeepSeek-V4-Pro-0813", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 4e-08 }, "nebius/deepseek-ai/DeepSeek-V4.1-Flash": { "input_cost_per_token": 3e-07, "litellm_provider": "nebius", - "max_input_tokens": 1048000, + "max_input_tokens": 1048576, "max_output_tokens": 384000, "mode": "chat", "output_cost_per_token": 1.2e-06, - "source": "https://tokenfactory.nebius.com/endpoints?modals=endpoint-details&model-id=deepseek-ai/DeepSeek-V4.1-Flash", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, "supports_reasoning": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1e-08 }, "nebius/MiniMaxAI/MiniMax-M2.5": { "max_input_tokens": 196608, @@ -44555,7 +44617,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/MiniMaxAI%2FMiniMax-M3" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 6e-08 }, "nebius/moonshotai/Kimi-K2.6": { "max_input_tokens": 262144, @@ -44567,7 +44630,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K2.6" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.6e-07 }, "nebius/moonshotai/Kimi-K2.7-Code": { "max_input_tokens": 262144, @@ -44579,10 +44643,11 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/moonshotai%2FKimi-K2.7-Code" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.5e-07 }, "nebius/moonshotai/Kimi-K3": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, @@ -44591,7 +44656,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/moonshotai%2FKimi-K3" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 3e-07 }, "nebius/NousResearch/Hermes-4-405B": { "max_input_tokens": 131072, @@ -44602,7 +44668,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/NousResearch%2FHermes-4-405B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 2e-07 }, "nebius/NousResearch/Hermes-4-70B": { "max_input_tokens": 131072, @@ -44646,7 +44713,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNVIDIA-Nemotron-3-Nano-30B-A3B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-08 }, "nebius/nvidia/Nemotron-3-Nano-Omni": { "max_input_tokens": 262144, @@ -44669,7 +44737,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2Fnemotron-3-super-120b-a12b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 6e-08 }, "nebius/nvidia/Nemotron-3-Ultra-550b-a55b": { "max_input_tokens": 1048576, @@ -44680,7 +44749,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3-Ultra-550b-a55b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-07 }, "nebius/nvidia/Nemotron-3_5-Lightning": { "max_input_tokens": 1048576, @@ -44691,7 +44761,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/nvidia%2FNemotron-3_5-Lightning" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 3e-08 }, "nebius/openai/gpt-oss-120b": { "max_input_tokens": 131072, @@ -44702,7 +44773,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/openai%2Fgpt-oss-120b" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-07 }, "nebius/openbmb/MiniCPM-V-4_5": { "max_input_tokens": 32000, @@ -44712,7 +44784,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_vision": true, - "source": "https://tokenfactory.nebius.com/models/catalog/image2text/openbmb%2FMiniCPM-V-4_5" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.3e-07 }, "nebius/Qwen/Qwen3-235B-A22B-Instruct-2507": { "max_input_tokens": 262144, @@ -44722,7 +44795,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-235B-A22B-Instruct-2507" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 5e-08 }, "nebius/Qwen/Qwen3-30B-A3B-Instruct-2507": { "max_input_tokens": 262144, @@ -44732,7 +44806,8 @@ "litellm_provider": "nebius", "mode": "chat", "supports_function_calling": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3-30B-A3B-Instruct-2507" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1e-07 }, "nebius/Qwen/Qwen3-Next-80B-A3B-Thinking": { "max_input_tokens": 128000, @@ -44754,7 +44829,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3.5-397B-A17B" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.2e-07 }, "nebius/Qwen/Qwen3.8-27B": { "input_cost_per_token": 4.5e-07, @@ -44762,9 +44838,10 @@ "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 3e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/Qwen%2FQwen3.8-27B", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 1e-07 }, "nebius/zai-org/GLM-5.1": { "max_input_tokens": 202752, @@ -44775,7 +44852,8 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.1" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 2.6e-07 }, "nebius/zai-org/GLM-5.2": { "max_input_tokens": 1048576, @@ -44786,21 +44864,23 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.2" + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "cache_read_input_token_cost": 1.4e-07 }, "nebius/zai-org/GLM-5.3": { "input_cost_per_token": 1.4e-06, "litellm_provider": "nebius", - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.3", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 2.6e-07 }, "nebius/zai-org/GLM-5.3-Flash": { - "max_input_tokens": 1024000, + "max_input_tokens": 1048576, "max_output_tokens": 1024000, "input_cost_per_token": 1.5e-07, "output_cost_per_token": 5e-07, @@ -44808,8 +44888,9 @@ "mode": "chat", "supports_function_calling": true, "supports_reasoning": true, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/zai-org%2FGLM-5.3-Flash", - "supports_vision": true + "source": "https://tokenfactory.nebius.com/api/public/models_info", + "supports_vision": true, + "cache_read_input_token_cost": 3e-08 }, "nebius/BAAI/bge-en-icl": { "max_input_tokens": 32768, @@ -49813,7 +49894,7 @@ "mode": "chat", "output_cost_per_token": 3e-05, "prompt_cache_min_tokens": 512, - "source": "https://aws.amazon.com/bedrock/pricing/", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-west-1/index.json", "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, @@ -49835,7 +49916,9 @@ "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-gov-west-1" - } + }, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05 }, "us-gov.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -52922,6 +53005,7 @@ }, "vertex_ai/gemini-3.1-flash-lite-image": { + "deprecation_date": "2027-06-28", "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -54150,7 +54234,7 @@ "voyage/voyage-lite-01": { "input_cost_per_token": 1e-07, "litellm_provider": "voyage", - "max_input_tokens": 4096, + "max_input_tokens": 4000, "mode": "embedding", "output_cost_per_token": 0.0 }, @@ -64809,7 +64893,7 @@ "supports_vision": false }, "novita/deepseek/deepseek-v4-pro-0813": { - "cache_read_input_token_cost": 1.3200000000000002e-07, + "cache_read_input_token_cost": 4.4e-08, "input_cost_per_token": 1.32e-06, "litellm_provider": "novita", "max_input_tokens": 1048576, @@ -64921,13 +65005,13 @@ "supports_vision": false }, "novita/mindai/macaron-v1-venti": { - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 1.5e-06, + "cache_read_input_token_cost": 1.65e-07, + "input_cost_per_token": 8.25e-07, "litellm_provider": "novita", "max_input_tokens": 1048576, "max_output_tokens": 131072, "mode": "chat", - "output_cost_per_token": 4.5e-06, + "output_cost_per_token": 2.475e-06, "source": "https://novita.ai/pricing", "supports_function_calling": true, "supports_prompt_caching": true, @@ -65030,13 +65114,13 @@ "supports_vision": false }, "novita/mindai/macaron-v1-tall": { - "cache_read_input_token_cost": 8e-08, - "input_cost_per_token": 4.5000000000000003e-07, + "cache_read_input_token_cost": 4.4e-08, + "input_cost_per_token": 2.475e-07, "litellm_provider": "novita", "max_input_tokens": 262144, "max_output_tokens": 32768, "mode": "chat", - "output_cost_per_token": 2.6e-06, + "output_cost_per_token": 1.43e-06, "source": "https://novita.ai/pricing", "supports_function_calling": true, "supports_prompt_caching": true, @@ -66461,9 +66545,9 @@ "deepinfra/deepseek-ai/DeepSeek-V4-Flash-0731": { "max_output_tokens": 1048576, "max_input_tokens": 1048576, - "input_cost_per_token": 8e-08, + "input_cost_per_token": 6e-08, "output_cost_per_token": 1.8e-07, - "cache_read_input_token_cost": 1.6e-08, + "cache_read_input_token_cost": 1.5e-08, "supports_prompt_caching": true, "litellm_provider": "deepinfra", "mode": "chat", @@ -66842,8 +66926,8 @@ "deepinfra/google/gemma-4-31B-it": { "max_output_tokens": 262144, "max_input_tokens": 262144, - "input_cost_per_token": 1.3e-07, - "output_cost_per_token": 3.8e-07, + "input_cost_per_token": 2e-07, + "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_function_calling": true, @@ -68554,7 +68638,10 @@ "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "us-gov-west-1" - } + }, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-west-1/index.json" }, "bedrock/us-gov-west-1/anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -68829,7 +68916,10 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_batches": 3e-06, + "output_cost_per_token_batches": 1.5e-05, + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-gov-east-1/index.json" }, "bedrock/us-gov-east-1/anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -69852,6 +69942,14 @@ "cache_read_input_token_cost": 3e-08, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, + "perplexity/google/gemini-4-argon": { + "litellm_provider": "perplexity", + "mode": "responses", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1e-07, + "source": "https://docs.perplexity.ai/docs/agent-api/models" + }, "perplexity/google/gemini-3.6-flash": { "litellm_provider": "perplexity", "mode": "responses", @@ -69979,6 +70077,14 @@ "cache_read_input_token_cost": 1.15e-09, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, + "perplexity/perplexity/nemotron-3.5-super-120b-a12b": { + "litellm_provider": "perplexity", + "mode": "responses", + "input_cost_per_token": 2e-07, + "output_cost_per_token": 5e-07, + "cache_read_input_token_cost": 4e-08, + "source": "https://docs.perplexity.ai/docs/agent-api/models" + }, "perplexity/perplexity/nemotron-3-ultra-550b-a55b": { "litellm_provider": "perplexity", "mode": "responses", @@ -70104,12 +70210,12 @@ "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai", "max_output_tokens": 57920, - "max_input_tokens": 57920, + "max_input_tokens": 131072, "mode": "chat", "output_cost_per_reasoning_token": 9e-06, "output_cost_per_token": 9e-06, "output_cost_per_video_token": 1.75e-05, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-1-1-flash", "supported_endpoints": [ "/v1beta/interactions" ], @@ -74505,12 +74611,14 @@ "input_cost_per_token": 5.5e-06, "cache_read_input_token_cost": 5.5e-07, "output_cost_per_token": 2.75e-05, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", + "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/ap-northeast-1/index.json", "token_count_model": { "model": "anthropic.claude-opus-5", "litellm_provider": "bedrock_mantle", "region": "ap-southeast-4" - } + }, + "input_cost_per_token_batches": 2.75e-06, + "output_cost_per_token_batches": 1.375e-05 }, "apac.anthropic.claude-opus-5-5": { "bedrock_converse_supports_strict_tools": false, @@ -75107,7 +75215,7 @@ "supports_function_calling": true, "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, - "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost": 1e-07, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "perplexity/anthropic/claude-fable-5-1": { @@ -75155,6 +75263,9 @@ "output_cost_per_token": 5e-05, "cache_read_input_token_cost": 1e-06, "cache_creation_input_token_cost": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "perplexity/openai/gpt-6-luna": { @@ -76622,6 +76733,9 @@ "input_cost_per_token": 1e-07, "output_cost_per_token": 5e-07, "cache_read_input_token_cost": 1e-08, + "input_cost_per_token_above_100k_tokens": 5e-07, + "output_cost_per_token_above_100k_tokens": 2.5e-06, + "cache_read_input_token_cost_above_100k_tokens": 5e-08, "source": "https://docs.perplexity.ai/docs/agent-api/models" }, "us-gov.anthropic.claude-haiku-5-5": { @@ -77096,6 +77210,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-4-argon": { + "deprecation_date": "2027-10-13", "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, @@ -77504,9 +77619,10 @@ "max_output_tokens": 1048576, "mode": "chat", "output_cost_per_token": 1e-06, - "source": "https://tokenfactory.nebius.com/models/catalog/text2text/reflection%2Fbeam", + "source": "https://tokenfactory.nebius.com/api/public/models_info", "supports_function_calling": true, - "supports_reasoning": true + "supports_reasoning": true, + "cache_read_input_token_cost": 5e-08 }, "baseten/deepseek-ai/DeepSeek-V4.1-Flash-1017": { "cache_read_input_token_cost": 7e-09, @@ -81777,5 +81893,55 @@ "supports_response_schema": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true + }, + "nebius/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 6e-09, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/meta-models/muse-spark-open": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.25e-06, + "cache_read_input_token_cost": 1.5e-07, + "output_cost_per_token": 4.25e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_video_input": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/MiniMaxAI/MiniMax-M3.1-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.5e-07, + "cache_read_input_token_cost": 1.5e-08, + "output_cost_per_token": 6e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" + }, + "nebius/nvidia/Nemotron-3.5-Super-VL": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 6e-08, + "output_cost_per_token": 9e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "supports_video_input": true, + "supports_reasoning": true, + "supports_function_calling": true, + "source": "https://tokenfactory.nebius.com/api/public/models_info" } } diff --git a/tests/integration/spend/test_session_grouped_logs_pages.py b/tests/integration/spend/test_session_grouped_logs_pages.py new file mode 100644 index 00000000000..19854d60bd3 --- /dev/null +++ b/tests/integration/spend/test_session_grouped_logs_pages.py @@ -0,0 +1,455 @@ +import itertools +import json +import os +import uuid +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Final +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import psycopg +import pytest +from prisma import Prisma +from psycopg import sql +from pydantic import BaseModel, ConfigDict, Field + +from litellm.proxy.spend_tracking.session_page import read_session_page +from litellm.proxy.spend_tracking.spend_management_endpoints import _fetch_session_representatives +from tests.integration._support.client import Gateway +from tests.integration._support.database import write_rows + +_URL: Final = "/spend/logs/ui" +_WINDOW_SECONDS: Final = 3600 + + +@dataclass(frozen=True, slots=True) +class _Row: + request: str + session: str | None + api_key: str + second: int + status: str = "success" + model: str = "m-a" + + +_ROWS: Final = ( + _Row("z1", "z", "key-1", -1), + _Row("a1", "a", "key-1", 10), + _Row("a2", "a", "key-1", 50), + _Row("b1", "b", "key-1", 20, model="m-b"), + _Row("b2", "b", "key-1", 60), + _Row("b3", "b", "key-1", 30), + _Row("c1", "c", "key-2", 60), + _Row("d1", "b", "key-2", 40, model="m-b"), + _Row("e1", None, "key-1", 55, model="m-b"), + _Row("f1", "", "key-1", 45), + _Row("g1", "g", "key-3", 5, status="failure"), + _Row("g2", "g", "key-3", 15, status="failure"), + *(_Row(f"h{second - 69}", "h", "key-1", second) for second in range(70, 76)), + _Row("i1", "i", "key-3", 35, status="failure"), + _Row("i2", "i", "key-3", _WINDOW_SECONDS + 1), +) +_NEWEST_FIRST: Final = ( + ("h", "key-1"), + ("c", "key-2"), + ("b", "key-1"), + ("e1", "key-1"), + ("a", "key-1"), + ("f1", "key-1"), + ("b", "key-2"), + ("i", "key-3"), + ("g", "key-3"), +) + + +class _LogRow(BaseModel): + model_config = ConfigDict(frozen=True) + + request_id: str + session_id: str | None + api_key: str + session_total_count: int + + +class _Page(BaseModel): + model_config = ConfigDict(frozen=True) + + data: tuple[_LogRow, ...] + total: int + total_is_capped: bool + has_more: bool + next_session_cursor: str | None + + +@dataclass(frozen=True, slots=True) +class _Seeded: + prefix: str + window: Mapping[str, str] + + def session(self, row: _LogRow) -> tuple[str, str]: + key: Final = row.session_id or row.request_id + assert key.startswith(self.prefix), key + return key.removeprefix(self.prefix), row.api_key + + +@pytest.fixture +def seeded(gateway: Gateway) -> Iterator[_Seeded]: + prefix: Final = f"sess{uuid.uuid4().hex[:12]}-" + start: Final = datetime(1900, 1, 1) + timedelta(days=int(prefix[4:16], 16) % 200000) + rows: Final = [ + { + "request_id": f"{prefix}{row.request}", + "session_id": None if row.session is None else f"{prefix}{row.session}" if row.session else "", + "api_key": row.api_key, + "start_time": (start + timedelta(seconds=row.second)).isoformat(), + "status": row.status, + "model": row.model, + } + for row in _ROWS + ] + with gateway.scenario() as scenario: + scenario.cleanups.callback( + write_rows, 'DELETE FROM "LiteLLM_SpendLogs" WHERE request_id LIKE %s', (f"{prefix}%",) + ) + write_rows( + 'INSERT INTO "LiteLLM_SpendLogs" (request_id, call_type, api_key, "startTime", "endTime", model, ' + "session_id, status) SELECT request_id, 'acompletion', api_key, start_time, start_time, model, session_id, " + "status FROM jsonb_to_recordset(%s::jsonb) AS seeded(request_id text, session_id text, api_key text, " + "start_time timestamp, status text, model text)", + (json.dumps(rows),), + ) + yield _Seeded( + prefix, + { + "start_date": f"{start:%Y-%m-%d %H:%M:%S}", + "end_date": f"{start + timedelta(seconds=_WINDOW_SECONDS):%Y-%m-%d %H:%M:%S}", + "group_by_session": "true", + }, + ) + + +def _page(gateway: Gateway, seeded: _Seeded, params: Mapping[str, str]) -> _Page: + return _Page.model_validate(gateway.get(_URL, {**seeded.window, **params})) + + +def _cursor_walk(gateway: Gateway, seeded: _Seeded, params: Mapping[str, str], number: int = 1) -> tuple[_Page, ...]: + page: Final = _page(gateway, seeded, {**params, "page_size": "2", "page": str(number)}) + if page.next_session_cursor is None or number > len(_NEWEST_FIRST): + return (page,) + following: Final = {**params, "session_cursor": page.next_session_cursor} + return (page, *_cursor_walk(gateway, seeded, following, number + 1)) + + +def _rows(pages: tuple[_Page, ...]) -> Iterator[_LogRow]: + return itertools.chain.from_iterable(page.data for page in pages) + + +def _sessions(seeded: _Seeded, pages: tuple[_Page, ...]) -> tuple[tuple[str, str], ...]: + return tuple(seeded.session(row) for row in _rows(pages)) + + +def test_cursor_pages_show_every_session_once_newest_first_with_its_newest_row( + gateway: Gateway, seeded: _Seeded +) -> None: + pages: Final = _cursor_walk(gateway, seeded, {}) + assert _sessions(seeded, pages) == _NEWEST_FIRST + assert [(page.total, page.total_is_capped) for page in pages] == [(9, False)] * 5 + assert [page.has_more for page in pages] == [True, True, True, True, False] + representatives: Final = tuple( + (row.request_id.removeprefix(seeded.prefix), row.session_total_count) for row in _rows(pages) + ) + assert representatives == ( + ("h6", 6), + ("c1", 1), + ("b2", 3), + ("e1", 1), + ("a2", 2), + ("f1", 1), + ("d1", 1), + ("i1", 2), + ("g2", 2), + ) + + +@pytest.mark.parametrize( + "sort_order, expected", + [("desc", _NEWEST_FIRST), ("asc", tuple(reversed(_NEWEST_FIRST)))], + ids=["newest-first", "oldest-first"], +) +def test_numbered_pages_show_the_same_sessions_as_the_cursor_walk( + gateway: Gateway, seeded: _Seeded, sort_order: str, expected: tuple[tuple[str, str], ...] +) -> None: + pages: Final = tuple( + _page(gateway, seeded, {"page_size": "2", "page": str(number), "sort_order": sort_order}) + for number in range(1, 7) + ) + assert _sessions(seeded, pages) == expected + assert [len(page.data) for page in pages] == [2, 2, 2, 2, 1, 0] + assert [page.has_more for page in pages] == [True, True, True, True, False, False] + assert {page.total for page in pages} == {len(expected)} + + +def test_failure_filter_keeps_only_sessions_whose_outcome_failed(gateway: Gateway, seeded: _Seeded) -> None: + pages: Final = _cursor_walk(gateway, seeded, {"status_filter": "failure"}) + assert _sessions(seeded, pages) == (("i", "key-3"), ("g", "key-3")) + assert pages[0].total == 2 + + +def test_model_filter_ranks_each_session_by_its_matching_rows_only(gateway: Gateway, seeded: _Seeded) -> None: + pages: Final = _cursor_walk(gateway, seeded, {"model": "m-b"}) + assert _sessions(seeded, pages) == (("e1", "key-1"), ("b", "key-2"), ("b", "key-1")) + assert tuple(row.request_id.removeprefix(seeded.prefix) for row in _rows(pages)) == ("e1", "d1", "b1") + assert pages[0].total == 3 + + +_COPIED_ROWS: Final = 20_000 +_COPY_START: Final = datetime(2000, 1, 1, tzinfo=UTC) +_COPY_WINDOW: Final = ( + "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC') AND \"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')" +) +_COPY_WINDOW_PARAMS: Final = (_COPY_START, _COPY_START + timedelta(days=1)) + + +def _generated_sessions(rows_per_session: int) -> tuple[sql.SQL, tuple[object, ...]]: + """`_COPIED_ROWS` rows a second apart in sessions of `rows_per_session` rows, a one-row session keyed by request_id.""" + return sql.SQL( + 'INSERT INTO {table} (request_id, call_type, api_key, "startTime", "endTime", session_id) ' + "SELECT 'r' || n, 'acompletion', 'key-1', moment, moment, " + "CASE WHEN %s > 1 THEN 's' || (n - 1) / %s END FROM generate_series(1, %s) n, " + "LATERAL (SELECT %s::timestamp + n * INTERVAL '1 second' AS moment) seconds" + ), (rows_per_session, rows_per_session, _COPIED_ROWS, _COPY_START.replace(tzinfo=None)) + + +def _sessions_tied_at_the_newest_moment() -> tuple[sql.SQL, tuple[object, ...]]: + """`_generated_sessions(1)` plus thirty one-row sessions sharing a newer moment, inserted and keyed against session + order so a read cut inside the tie misses the sessions that rank first.""" + start: Final = _COPY_START.replace(tzinfo=None) + return sql.SQL( + 'INSERT INTO {table} (request_id, call_type, api_key, "startTime", "endTime", session_id) ' + "SELECT request_id, 'acompletion', 'key-1', moment, moment, session_id FROM (" + "SELECT n AS ordinal, 'r' || n AS request_id, %s::timestamp + n * INTERVAL '1 second' AS moment, " + "NULL AS session_id FROM generate_series(1, %s) n UNION ALL " + "SELECT %s + 30 - n, 't' || lpad((29 - n)::text, 2, '0'), %s::timestamp + %s * INTERVAL '1 second', " + "'s' || lpad(n::text, 2, '0') FROM generate_series(0, 29) n) seeded ORDER BY ordinal" + ), (start, _COPIED_ROWS, _COPIED_ROWS, start, _COPIED_ROWS + 1) + + +@dataclass(frozen=True, slots=True) +class _Call: + session: str + call_type: str + status: str | None + second: int + + +def _calls(calls: tuple[_Call, ...]) -> tuple[sql.SQL, tuple[object, ...]]: + """One row per call, `second` seconds into the window, ending a second later.""" + seeded: Final = tuple( + { + "request_id": f"{call.session}-{call.second}", + "call_type": call.call_type, + "session_id": call.session, + "status": call.status, + "start_time": (_COPY_START + timedelta(seconds=call.second)).replace(tzinfo=None).isoformat(), + } + for call in calls + ) + return sql.SQL( + 'INSERT INTO {table} (request_id, call_type, api_key, "startTime", "endTime", session_id, status) ' + "SELECT request_id, call_type, 'key-1', start_time, start_time + INTERVAL '1 second', session_id, status " + "FROM jsonb_to_recordset(%s::jsonb) AS calls(request_id text, call_type text, session_id text, status text, " + "start_time timestamp)" + ), (json.dumps(seeded),) + + +@asynccontextmanager +async def _spend_logs_copy(insert: sql.SQL, args: tuple[object, ...]) -> AsyncIterator[Prisma]: + """A private schema holding a copy of the migrated `LiteLLM_SpendLogs` with its indexes, seeded by `insert`.""" + schema: Final = f"integration_{uuid.uuid4().hex}" + url: Final = os.environ["DATABASE_URL"] + table: Final = sql.Identifier(schema, "LiteLLM_SpendLogs") + with psycopg.connect(url, autocommit=True) as setup: + setup.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + setup.execute(sql.SQL('CREATE TABLE {} (LIKE "LiteLLM_SpendLogs" INCLUDING ALL)').format(table)) + setup.execute(insert.format(table=table), args) + setup.execute(sql.SQL("ANALYZE {}").format(table)) + parsed: Final = urlsplit(url) + query: Final = urlencode({**dict(parse_qsl(parsed.query)), "schema": schema}) + database: Final = Prisma(datasource={"url": urlunsplit(parsed._replace(query=query))}) + await database.connect() + try: + yield database + finally: + await database.disconnect() + finally: + setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + + +class _PlanNode(BaseModel): + model_config = ConfigDict(frozen=True) + + relation_name: str | None = Field(default=None, alias="Relation Name") + actual_rows: float = Field(alias="Actual Rows") + rows_removed_by_filter: float = Field(default=0, alias="Rows Removed by Filter") + actual_loops: int = Field(alias="Actual Loops") + plans: tuple["_PlanNode", ...] = Field(default=(), alias="Plans") + + def spend_log_rows_scanned(self) -> int: + own: Final = ( + (self.actual_rows + self.rows_removed_by_filter) * self.actual_loops + if self.relation_name == "LiteLLM_SpendLogs" + else 0 + ) + return round(own) + sum(child.spend_log_rows_scanned() for child in self.plans) + + +class _Explained(BaseModel): + model_config = ConfigDict(frozen=True) + + plan: _PlanNode = Field(alias="Plan") + + +class _ExplainedReads: + """Runs each read through Prisma, first under EXPLAIN ANALYZE to count the `LiteLLM_SpendLogs` rows it scans.""" + + def __init__(self, database: Prisma) -> None: + self.database: Final = database + self.rows_scanned: tuple[int, ...] = () + + async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: + explained: Final = await self.database.query_raw(f"EXPLAIN (ANALYZE, FORMAT JSON) {query}", *args) + plan: Final = _Explained.model_validate(explained[0]["QUERY PLAN"][0]).plan + self.rows_scanned = (*self.rows_scanned, plan.spend_log_rows_scanned()) + return await self.database.query_raw(query, *args) + + +@pytest.mark.parametrize( + "rows_per_session, page, newest_session, rows_scanned_per_read, total", + [ + pytest.param(1, 1, ("r", 20_000), (102,), (100, True), id="first-page"), + pytest.param(1, 3, ("r", 19_980), (102,), (100, True), id="third-page"), + pytest.param(10, 1, ("s", 1_999), (102, 1_171), (100, True), id="second-read-sized-by-the-first-reads-density"), + pytest.param(100, 1, ("s", 199), (102, 6_439, 12_878), (100, True), id="third-read-at-least-doubled"), + pytest.param(1_000, 1, ("s", 19), (102, 12_878, 20_000), (20, False), id="last-read-runs-out-of-rows"), + ], +) +async def test_a_newest_first_page_scans_only_the_rows_its_reads_ask_for( + rows_per_session: int, + page: int, + newest_session: tuple[str, int], + rows_scanned_per_read: tuple[int, ...], + total: tuple[int, bool], +) -> None: + async with _spend_logs_copy(*_generated_sessions(rows_per_session)) as database: + reads: Final = _ExplainedReads(database) + session_page: Final = await read_session_page( + reads, + where_clause=_COPY_WINDOW, + params=_COPY_WINDOW_PARAMS, + page=page, + page_size=10, + status=None, + sort_desc=True, + session_cursor=None, + cap=100, + ) + prefix, newest = newest_session + assert tuple(session.key for session in session_page.sessions) == tuple( + f"{prefix}{newest - n}" for n in range(10) + ) + assert (session_page.total, session_page.total_is_capped) == total + assert reads.rows_scanned == rows_scanned_per_read + + +async def test_a_page_cut_inside_a_tie_reads_on_until_the_tie_is_whole() -> None: + async with _spend_logs_copy(*_sessions_tied_at_the_newest_moment()) as database: + reads: Final = _ExplainedReads(database) + session_page: Final = await read_session_page( + reads, + where_clause=_COPY_WINDOW, + params=_COPY_WINDOW_PARAMS, + page=1, + page_size=5, + status=None, + sort_desc=True, + session_cursor=None, + cap=10, + ) + assert tuple(session.key for session in session_page.sessions) == ("s29", "s28", "s27", "s26", "s25") + assert reads.rows_scanned == (12, 24, 48) + + +@dataclass(frozen=True, slots=True) +class _ExplainedPrisma: + db: _ExplainedReads + + +async def test_a_pages_representatives_are_found_by_session_not_by_scanning_the_window() -> None: + async with _spend_logs_copy(*_generated_sessions(rows_per_session=10)) as database: + reads: Final = _ExplainedReads(database) + representatives: Final = await _fetch_session_representatives( + prisma_client=_ExplainedPrisma(reads), # pyright: ignore[reportArgumentType] # only .db is used + where_clause=_COPY_WINDOW, + sql_params=_COPY_WINDOW_PARAMS, + next_param_index=len(_COPY_WINDOW_PARAMS) + 1, + session_keys=(("s1999", "key-1"), ("s1998", "key-1")), + ) + assert tuple(row["request_id"] for row in representatives) == ("r20000", "r19990") + assert reads.rows_scanned == (20,), "only the two sessions' rows, not the window's 20,000" + + +async def test_a_page_number_far_past_the_cap_shows_no_session() -> None: + async with _spend_logs_copy(*_generated_sessions(rows_per_session=1)) as database: + session_page: Final = await read_session_page( + _ExplainedReads(database), + where_clause=_COPY_WINDOW, + params=_COPY_WINDOW_PARAMS, + page=9_300_000_000_000_000, + page_size=1000, + status=None, + sort_desc=True, + session_cursor=None, + cap=100, + ) + assert (session_page.sessions, session_page.total, session_page.total_is_capped) == ((), 100, True) + + +_OUTCOME_CALLS: Final = ( + _Call("agent", "asend_message", "failure", 10), + _Call("agent", "acompletion", "success", 20), + _Call("mcp", "acompletion", "failure", 5), + _Call("mcp", "call_mcp_tool", "success", 30), + _Call("unknown", "acompletion", None, 40), + _Call("failed", "acompletion", "success", 1), + _Call("failed", "acompletion", "failure", 50), + _Call("recovered", "acompletion", "failure", 2), + _Call("recovered", "acompletion", "success", 45), +) + + +@pytest.mark.parametrize( + "status, expected", + [("failure", ("failed", "mcp", "agent")), ("success", ("recovered", "unknown"))], +) +async def test_a_status_filter_keeps_sessions_whose_representative_call_has_that_status( + status: str, expected: tuple[str, ...] +) -> None: + """An agent message stands for its session, then the newest call that is not an MCP tool call, and a missing status + counts as success. The page stays ordered by each session's newest call.""" + async with _spend_logs_copy(*_calls(_OUTCOME_CALLS)) as database: + session_page: Final = await read_session_page( + _ExplainedReads(database), + where_clause=_COPY_WINDOW, + params=_COPY_WINDOW_PARAMS, + status=status, + page=1, + page_size=10, + sort_desc=True, + session_cursor=None, + cap=100, + ) + assert tuple(session.key for session in session_page.sessions) == expected + assert (session_page.total, session_page.total_is_capped) == (len(expected), False) diff --git a/tests/unit/llms/azure_ai/test_azure_ai_foundry_catalog_model_metadata.py b/tests/unit/llms/azure_ai/test_azure_ai_foundry_catalog_model_metadata.py index 9b20192c3f2..a5b66efe7cd 100644 --- a/tests/unit/llms/azure_ai/test_azure_ai_foundry_catalog_model_metadata.py +++ b/tests/unit/llms/azure_ai/test_azure_ai_foundry_catalog_model_metadata.py @@ -22,6 +22,7 @@ TOKEN_PRICED_NAMES: Final = ( "cohere-command-a", "grok-4-20-reasoning", "grok-4-20-non-reasoning", + "claude-mythos-5-1", ) GROK_4_20_NAMES: Final = ("grok-4-20-reasoning", "grok-4-20-non-reasoning") CATALOG_NAMES: Final = TOKEN_PRICED_NAMES + ("whisper",) diff --git a/tests/unit/proxy/spend_tracking/test_session_page.py b/tests/unit/proxy/spend_tracking/test_session_page.py new file mode 100644 index 00000000000..a86990bddbb --- /dev/null +++ b/tests/unit/proxy/spend_tracking/test_session_page.py @@ -0,0 +1,327 @@ +import itertools +import random +import re +from collections.abc import Sequence +from typing import Final, TypeAlias + +import pytest + +from litellm.proxy.spend_tracking.session_page import ( + Session, + _parse_session_cursor, + _Read, + _ReadRow, + _Request, + _session_page, + read_session_page, +) + +_Row: TypeAlias = tuple[int, str, str] # (startTime, session key, api_key) +_Ranked: TypeAlias = tuple[int, str, str] # (last activity, session key, api_key) + + +def _session_of(row: _Row) -> tuple[str, str]: + return row[1], row[2] + + +def _ranked(rows: Sequence[_Row], request: _Request) -> tuple[_Ranked, ...]: + """The sessions in ``rows`` in page order, after the request's cursor or offset.""" + by_session: Final = itertools.groupby(sorted(rows, key=_session_of), key=_session_of) + ranked: Final = tuple( + sorted( + ((max(start for start, _, _ in group), key, api_key) for (key, api_key), group in by_session), + reverse=request.sort_desc, + ) + ) + if request.cursor is None: + return ranked[request.offset :] + cursor: Final = (int(request.cursor[0]), request.cursor[1], request.cursor[2]) + return tuple(session for session in ranked if (session < cursor if request.sort_desc else session > cursor)) + + +class _Reads: + """Reads like the page query, whose LIMIT cuts tied rows lowest key first, the opposite of page order. It fails a + read no larger than the one before, since only growing reads end the loop.""" + + def __init__(self, rows: Sequence[_Row], request: _Request, every_row: bool = False) -> None: + self.newest_first: Final = tuple(sorted(rows, key=lambda row: (-row[0], row[1], row[2]))) + self.request: Final = request + self.every_row: Final = every_row + self.budgets: tuple[int | None, ...] = () + + async def __call__(self, budget: int | None) -> _Read: + assert not self.budgets or (self.budgets[-1] is not None and (budget is None or budget > self.budgets[-1])), ( + self.budgets, + budget, + ) + self.budgets = (*self.budgets, budget) + taken: Final = self.newest_first if budget is None or self.every_row else self.newest_first[:budget] + page: Final = _ranked(taken, self.request)[: self.request.page_limit + 1] if self.request.page_limit > 0 else () + return _Read( + candidates=tuple(Session(key, api_key, f"{at:06d}") for at, key, api_key in page), + last_settled=bool(page) and page[-1][0] > taken[-1][0], + rows_read=len(taken), + session_count=len(frozenset(_session_of(row) for row in taken)), + saw_every_row=budget is None or self.every_row or len(taken) < budget, + ) + + +def _session_rows(rng: random.Random, index: int, clock: int) -> tuple[_Row, ...]: + start, length = rng.randrange(0, clock), rng.choice((1, 1, 2, 3, 8, 40)) + return tuple((start + rng.randrange(0, 30), f"s{index:03d}", rng.choice(("k1", "k2"))) for _ in range(length)) + + +def _sessions(rng: random.Random) -> tuple[_Row, ...]: + """Sessions of one to forty rows over a short clock, so many rows share a time.""" + clock: Final = rng.choice((3, 40, 400)) + return tuple( + itertools.chain.from_iterable(_session_rows(rng, index, clock) for index in range(rng.randrange(1, 120))) + ) + + +def _random_cursor(rng: random.Random, ranked: Sequence[_Ranked]) -> tuple[str, str, str]: + at, key, api_key = ranked[rng.randrange(len(ranked))] + return f"{at:06d}", key, api_key + + +def _request(rng: random.Random, rows: Sequence[_Row]) -> _Request: + cap, page_size, sort_desc = rng.randrange(2, 60), rng.randrange(1, 12), rng.random() < 0.8 + mode: Final = rng.choice(("first", "offset", "cursor")) + full: Final = _ranked(rows, _Request(None, 0, 0, sort_desc, None, cap)) + cursor: Final = _random_cursor(rng, full) if mode == "cursor" and full else None + offset: Final = (rng.randrange(1, 2 * cap // page_size + 3) - 1) * page_size if mode == "offset" else 0 + return _Request(cursor, offset, min(page_size, cap - offset), sort_desc, None, cap) + + +@pytest.mark.parametrize("seed", range(60)) +@pytest.mark.asyncio +async def test_a_page_read_from_the_newest_rows_matches_grouping_every_row(seed: int) -> None: + rng: Final = random.Random(seed) + rows: Final = _sessions(rng) + for request in tuple(_request(rng, rows) for _ in range(12)): + grouped = await _session_page(_Reads(rows, request, every_row=True), request) + assert await _session_page(_Reads(rows, request), request) == grouped, request + + +@pytest.mark.parametrize( + "rows_per_session, expected_budgets", + [ + pytest.param(1, (102,), id="one-read-holds-the-cap"), + pytest.param(2, (102, 253), id="sized-by-the-first-reads-density"), + pytest.param(50, (102, 4293, 8586), id="doubles-when-the-density-misleads"), + pytest.param(500, (102, 12878, 62533), id="re-sized-by-each-reads-density"), + ], +) +@pytest.mark.asyncio +async def test_reads_grow_by_the_last_reads_rows_per_session_until_one_settles_the_page( + rows_per_session: int, expected_budgets: tuple[int, ...] +) -> None: + """150 sessions a row per second apart. A cap of 100 starts at 100 + 2 rows.""" + rows: Final = tuple((index, f"s{index // rows_per_session:05d}", "k") for index in range(150 * rows_per_session)) + request: Final = _Request(cursor=None, offset=0, page_limit=10, sort_desc=True, status=None, cap=100) + reads: Final = _Reads(rows, request) + await _session_page(reads, request) + assert reads.budgets == expected_budgets + + +@pytest.mark.asyncio +async def test_a_session_tied_with_the_reads_oldest_row_is_not_trusted() -> None: + """Thirty one-row sessions share one time and the first read takes 12 of them. The page's sixth session sits at the + read's oldest time, where the LIMIT may have cut a session that ranks before it, so the page reads on.""" + rows: Final = tuple((0, f"s{index:02d}", "k") for index in range(30)) + request: Final = _Request(cursor=None, offset=0, page_limit=5, sort_desc=True, status=None, cap=10) + reads: Final = _Reads(rows, request) + page: Final = await _session_page(reads, request) + assert tuple(session.key for session in page.sessions) == ("s29", "s28", "s27", "s26", "s25") + assert reads.budgets == (12, 24, 48) + + +@pytest.mark.parametrize("session_count, expected_total", [(100, (100, False)), (101, (100, True))]) +@pytest.mark.asyncio +async def test_the_total_is_capped_only_past_the_cap(session_count: int, expected_total: tuple[int, bool]) -> None: + rows: Final = tuple((index, f"s{index:05d}", "k") for index in range(session_count)) + request: Final = _Request(cursor=None, offset=0, page_limit=10, sort_desc=True, status=None, cap=100) + page: Final = await _session_page(_Reads(rows, request), request) + assert (page.total, page.total_is_capped) == expected_total + + +@pytest.mark.asyncio +async def test_a_page_number_near_the_bigint_limit_reads_once() -> None: + rows: Final = tuple((index, f"s{index:05d}", "k") for index in range(500)) + offset: Final = (9_300_000_000_000_000 - 1) * 1000 + request: Final = _Request( + cursor=None, offset=offset, page_limit=min(1000, 100 - offset), sort_desc=True, status=None, cap=100 + ) + reads: Final = _Reads(rows, request) + page: Final = await _session_page(reads, request) + assert reads.budgets == (102,) + assert (page.sessions, page.total, page.total_is_capped) == ((), 100, True) + + +@pytest.mark.parametrize( + "sort_desc, status", + [pytest.param(False, None, id="oldest-first"), pytest.param(True, "failure", id="filtered-by-outcome")], +) +@pytest.mark.asyncio +async def test_a_page_that_needs_every_row_reads_them_once(sort_desc: bool, status: str | None) -> None: + rows: Final = tuple((index, f"s{index:05d}", "k") for index in range(500)) + request: Final = _Request(cursor=None, offset=0, page_limit=10, sort_desc=sort_desc, status=status, cap=100) + reads: Final = _Reads(rows, request) + await _session_page(reads, request) + assert reads.budgets == (None,) + + +def test_parse_session_cursor() -> None: + assert _parse_session_cursor(None) is None + assert _parse_session_cursor("no-delimiter") is None + assert _parse_session_cursor("2026-08-29 10:00:00|sess-1") is None + assert _parse_session_cursor("|hashed-key|sess-1") is None + assert _parse_session_cursor("2026-08-29 10:00:00|hashed-key|") is None + assert _parse_session_cursor("2026-08-29 10:00:00|hashed-key|sess-1") == ( + "2026-08-29 10:00:00", + "sess-1", + "hashed-key", + ) + assert _parse_session_cursor("2026-08-29 10:00:00.123|hashed-key|sess|with|pipes") == ( + "2026-08-29 10:00:00.123", + "sess|with|pipes", + "hashed-key", + ) + + +def _bound(sql: str, args: tuple[object, ...], pattern: str) -> tuple[object, ...]: + """The values bound to the ``$N`` placeholders that ``pattern``'s groups capture in ``sql``.""" + found: Final = re.search(pattern, sql) + assert found, f"{pattern!r} not found in:\n{sql}" + assert all(1 <= int(index) <= len(args) for index in found.groups()), f"{found.groups()} outside $1..${len(args)}" + return tuple(args[int(index) - 1] for index in found.groups()) + + +class _TableDb: + """A table of ``rows`` matching rows in ``sessions`` sessions, whose reads return no page row.""" + + def __init__(self, rows: int, sessions: int) -> None: + self.rows: Final = rows + self.sessions: Final = sessions + self.calls: tuple[tuple[str, tuple[object, ...]], ...] = () + + async def query_raw(self, query: str, *args: object) -> tuple[_ReadRow, ...]: + self.calls = (*self.calls, (query, args)) + budget: Final = re.search(r"DESC LIMIT (\d+)", query) + rows_read: Final = min(int(budget.group(1)), self.rows) if budget else self.rows + return ( + _ReadRow( + session_key=None, + api_key=None, + last_activity=None, + settled=None, + rows_read=rows_read, + session_count=self.sessions, + ), + ) + + +def _page_bounds(sql: str) -> tuple[int, int]: + """The LIMIT and OFFSET of the read's sessions.""" + found: Final = re.search(r"LIMIT (\d+)(?: OFFSET (\d+))?\s*\) AS page", sql) + assert found, sql + return int(found.group(1)), int(found.group(2) or 0) + + +def _read_size(sql: str) -> int | None: + found: Final = re.search(r"DESC LIMIT (\d+)", sql) + return int(found.group(1)) if found else None + + +@pytest.mark.asyncio +async def test_a_read_that_fills_its_size_is_not_taken_for_every_row() -> None: + """A read that took every row it asked for may have stopped short, so its 50 sessions are no total: the reads + grow until one takes fewer rows than it asked for.""" + db: Final = _TableDb(rows=9_000, sessions=50) + page: Final = await read_session_page( + db, + where_clause="TRUE", + params=(), + page=1, + page_size=10, + status=None, + sort_desc=True, + session_cursor=None, + cap=100, + ) + assert tuple(_read_size(sql) for sql, _ in db.calls) == (102, 258, 652, 1647, 4159, 10502) + assert (page.total, page.total_is_capped) == (50, False) + + +@pytest.mark.parametrize("page_number", [pytest.param(5, id="at-the-cap"), pytest.param(6, id="past-the-cap")]) +@pytest.mark.asyncio +async def test_a_page_starting_at_or_past_the_cap_reads_once_and_shows_no_session(page_number: int) -> None: + db: Final = _TableDb(rows=1_000, sessions=150) + page: Final = await read_session_page( + db, + where_clause="TRUE", + params=(), + page=page_number, + page_size=25, + status=None, + sort_desc=True, + session_cursor=None, + cap=100, + ) + assert len(db.calls) == 1, "the first read already holds more sessions than the cap" + assert _page_bounds(db.calls[0][0])[0] == 0 + assert (page.total, page.total_is_capped) == (100, True) + + +@pytest.mark.asyncio +async def test_the_read_binds_the_filters_and_the_cursor_to_their_placeholders() -> None: + db: Final = _TableDb(rows=50, sessions=40) + where, params = "model = $1 AND api_key = $2", ("gpt-4o", "key-a") + for page, page_size, session_cursor in ((1, 3, "2026-10-28 09:00:00|key-a|sess|9"), (3, 1, None)): + await read_session_page( + db, + where_clause=where, + params=params, + page=page, + page_size=page_size, + status=None, + sort_desc=True, + session_cursor=session_cursor, + cap=100, + ) + (cursor_sql, cursor_args), (offset_sql, _) = db.calls + for sql, args in db.calls: + assert {int(index) for index in re.findall(r"\$(\d+)", sql)} == set(range(1, len(args) + 1)), ( + "every argument needs a placeholder and every placeholder an argument" + ) + assert _bound(sql, args, r"model = \$(\d+) AND api_key = \$(\d+)") == params + assert _bound( + cursor_sql, cursor_args, r"\(last_activity, session_key, api_key\) < \(\$(\d+)::timestamp, \$(\d+), \$(\d+)\)" + ) == ("2026-10-28 09:00:00", "sess|9", "key-a") + assert _page_bounds(cursor_sql) == (4, 0) + assert _page_bounds(offset_sql) == (2, 2) + + +@pytest.mark.asyncio +async def test_a_status_filtered_read_binds_the_status_between_the_filters_and_the_cursor() -> None: + db: Final = _TableDb(rows=50, sessions=40) + await read_session_page( + db, + where_clause="model = $1", + params=("gpt-4o",), + status="failure", + page=1, + page_size=3, + sort_desc=True, + session_cursor="2026-10-28 09:00:00|key-a|sess-9", + cap=100, + ) + ((sql, args),) = db.calls + assert {int(index) for index in re.findall(r"\$(\d+)", sql)} == set(range(1, len(args) + 1)) + assert _bound(sql, args, r"model = \$(\d+)") == ("gpt-4o",) + assert _bound(sql, args, r"COALESCE\(status, 'success'\) = \$(\d+)") == ("failure",) + assert _bound(sql, args, r"\(last_activity, session_key, api_key\) < \(\$(\d+)::timestamp, \$(\d+), \$(\d+)\)") == ( + "2026-10-28 09:00:00", + "sess-9", + "key-a", + ) 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 6598b94e60f..f8d0ebb0f77 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -5,7 +5,7 @@ import hashlib import json import re import sqlite3 -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import timezone from typing import Final, Literal from unittest.mock import AsyncMock, MagicMock, patch @@ -7151,37 +7151,19 @@ def test_scoped_spend_report_range_at_max_allowed(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) -def test_parse_session_cursor(): - from litellm.proxy.spend_tracking.spend_management_endpoints import ( - _parse_session_cursor, - ) - - assert _parse_session_cursor(None) is None - assert _parse_session_cursor("no-delimiter") is None - assert _parse_session_cursor("2026-08-29 10:00:00|sess-1") is None - assert _parse_session_cursor("|hashed-key|sess-1") is None - assert _parse_session_cursor("2026-08-29 10:00:00|hashed-key|") is None - assert _parse_session_cursor("2026-08-29 10:00:00|hashed-key|sess-1") == ( - "2026-08-29 10:00:00", - "sess-1", - "hashed-key", - ) - assert _parse_session_cursor("2026-08-29 10:00:00.123|hashed-key|sess|with|pipes") == ( - "2026-08-29 10:00:00.123", - "sess|with|pipes", - "hashed-key", - ) - - SESSION_GROUP_KEY_SQL = "COALESCE(NULLIF(session_id, ''), request_id), api_key" -def _session_grouped_mock_prisma(session_page_rows, session_total, representative_rows): +def _session_grouped_mock_prisma( + session_page_rows: Sequence[Mapping[str, object]], + session_total: int, + representative_rows: Sequence[Mapping[str, object]], +) -> MagicMock: """Mock prisma dispatching the raw queries the keyset grouped path emits.""" - async def mock_query_raw(sql_query, *params): - if "COUNT(*) AS total_count" in sql_query: - return [{"total_count": session_total}] + async def mock_query_raw(sql_query: str, *params: object) -> Sequence[Mapping[str, object]]: + if "rows_read" in sql_query: + return _session_read_rows(session_page_rows, session_total) if "DISTINCT ON" in sql_query: return representative_rows if "COALESCE(SUM(spend)" in sql_query: @@ -7199,7 +7181,7 @@ def _session_grouped_mock_prisma(session_page_rows, session_total, representativ "session_models": ["gpt-4o"], } ] - return session_page_rows + raise AssertionError(f"unexpected query:\n{sql_query}") mock_prisma = MagicMock() mock_prisma.db = MagicMock() @@ -7228,23 +7210,33 @@ def _session_page_row(session_key, last_activity): return {"session_key": session_key, "api_key": "hashed-key", "last_activity": last_activity} -def _session_grouped_paginating_prisma(sessions, counted_total=None): - """Mock prisma serving the grouped page query out of ``sessions``, honoring the LIMIT and OFFSET it asks for.""" +def _session_read_rows( + page_rows: Sequence[Mapping[str, object]], session_count: int +) -> tuple[Mapping[str, object], ...]: + """The read query's rows for ``page_rows``, as if the read held every matching row.""" + summary: Final = {"rows_read": 0, "session_count": session_count} + if not page_rows: + return ({"session_key": None, "api_key": None, "last_activity": None, "settled": None, **summary},) + return tuple({**row, "settled": True, **summary} for row in page_rows) - async def mock_query_raw(sql_query, *params): - if "COUNT(*) AS total_count" in sql_query: - return [{"total_count": min(len(sessions) if counted_total is None else counted_total, params[-1])}] + +def _session_grouped_paginating_prisma(sessions: Sequence[tuple[str, str]]) -> MagicMock: + """Mock prisma serving the grouped page read out of ``sessions``, honoring the LIMIT and OFFSET it asks for.""" + + async def mock_query_raw(sql_query: str, *params: object) -> Sequence[Mapping[str, object]]: if "DISTINCT ON" in sql_query: return [_session_representative_row(f"req-{session_key}", session_key) for session_key in params[-2]] if "COALESCE(SUM(spend)" in sql_query: return [] - bounds = re.search(r"LIMIT \$(\d+)(?: OFFSET \$(\d+))?", sql_query) - limit = params[int(bounds.group(1)) - 1] - offset = params[int(bounds.group(2)) - 1] if bounds.group(2) else 0 - return [ - _session_page_row(session_key, last_activity) - for session_key, last_activity in sessions[offset : offset + limit] - ] + bounds: Final = re.search(r"LIMIT (\d+)(?: OFFSET (\d+))?\s+\) AS page", sql_query) + limit, offset = int(bounds.group(1)), int(bounds.group(2) or 0) + return _session_read_rows( + [ + _session_page_row(session_key, last_activity) + for session_key, last_activity in sessions[offset : offset + limit] + ], + len(sessions), + ) mock_prisma = MagicMock() mock_prisma.db = MagicMock() @@ -7296,20 +7288,15 @@ async def test_ui_view_spend_logs_group_by_session_first_page(client, monkeypatc assert data["data"][1]["session_total_count"] == 1 emitted = [call.args for call in mock_prisma.db.query_raw.await_args_list] + assert len(emitted) == 3, "one read picks the page and counts it, then the representatives and their totals" page_query_sql = emitted[0][0] - assert f"GROUP BY {SESSION_GROUP_KEY_SQL}" in page_query_sql assert "OFFSET" not in page_query_sql - assert "HAVING" not in page_query_sql - assert emitted[0][-1] == 3, "page query fetches page_size + 1 sessions to detect has_more" + assert "LIMIT 3 " in page_query_sql, "the read fetches page_size + 1 sessions to detect has_more" - count_sql = emitted[1][0] - assert f"GROUP BY {SESSION_GROUP_KEY_SQL}" in count_sql - assert "LIMIT" in count_sql and "FROM (" in count_sql, "the grouped count must stay bounded" - - rep_call = emitted[2] + rep_call: Final = emitted[1] assert f"DISTINCT ON ({SESSION_GROUP_KEY_SQL})" in rep_call[0] assert ( - f"ORDER BY {SESSION_GROUP_KEY_SQL}, " + spend_management_endpoints._SESSION_REPRESENTATIVE_ORDER_SQL + f"ORDER BY {SESSION_GROUP_KEY_SQL}, " + spend_management_endpoints.SESSION_REPRESENTATIVE_ORDER_SQL ) in rep_call[0] assert rep_call[-2] == ["sess-1", "req-solo"] assert rep_call[-1] == ["hashed-key", "hashed-key"] @@ -7319,7 +7306,7 @@ async def test_ui_view_spend_logs_group_by_session_first_page(client, monkeypatc @pytest.mark.asyncio async def test_ui_view_spend_logs_group_by_session_cursor_page(client, monkeypatch): - """A session_cursor becomes a HAVING keyset predicate instead of an OFFSET.""" + """A session_cursor becomes a keyset predicate on the read's sessions instead of an OFFSET.""" page_rows = [_session_page_row("sess-2", "2026-08-29 07:00:00")] reps = [_session_representative_row("req-2", "sess-2")] mock_prisma = _session_grouped_mock_prisma(page_rows, 3, reps) @@ -7352,7 +7339,7 @@ async def test_ui_view_spend_logs_group_by_session_cursor_page(client, monkeypat page_query_call = mock_prisma.db.query_raw.await_args_list[0] page_query_sql = page_query_call.args[0] - assert f'HAVING (MAX("startTime"), {SESSION_GROUP_KEY_SQL}) <' in page_query_sql + assert "WHERE (last_activity, session_key, api_key) <" in page_query_sql assert "OFFSET" not in page_query_sql cursor_index = page_query_call.args.index("2026-08-29 09:00:00") assert page_query_call.args[cursor_index : cursor_index + 3] == ( @@ -7403,9 +7390,9 @@ async def test_ui_view_spend_logs_group_by_session_jumps_to_page_without_cursor( @pytest.mark.asyncio async def test_ui_view_spend_logs_group_by_session_short_page_totals_itself(client, monkeypatch): - """A page that runs out of sessions is the end of the list, so the total comes from it and nothing is counted.""" + """A page that runs out of sessions has read every matching row, so that one read gives the total.""" sessions = tuple((f"sess-{index:02d}", f"2026-08-29 10:{59 - index:02d}:00") for index in range(10)) - mock_prisma = _session_grouped_paginating_prisma(sessions, counted_total=999) + mock_prisma: Final = _session_grouped_paginating_prisma(sessions) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) monkeypatch.setattr( "litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe", @@ -7429,10 +7416,12 @@ async def test_ui_view_spend_logs_group_by_session_short_page_totals_itself(clie ) assert response.status_code == 200, response.text data = response.json() - assert data["total"] == 10, "the count query's 999 would have won if it had been asked" + assert data["total"] == 10 assert data["total_is_capped"] is False assert data["total_pages"] == 1 assert len(data["data"]) == 10 + reads: Final = tuple(call for call in mock_prisma.db.query_raw.await_args_list if "rows_read" in call.args[0]) + assert len(reads) == 1, "a page that ends the list is not read again for its total" finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) @@ -7517,7 +7506,7 @@ async def test_ui_view_spend_logs_group_by_session_page_past_count_cap_is_empty( assert past_cap_data["data"] == [] assert past_cap_data["has_more"] is False assert past_cap_data["total"] == cap - assert mock_prisma.db.query_raw.await_count == 1, "only the bounded count query runs past the capped window" + assert mock_prisma.db.query_raw.await_count == 1, "only the bounded read runs past the capped window" finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) @@ -7820,14 +7809,14 @@ def test_session_representative_uses_completed_agent_outcome(parent_status, chil ) result = connection.execute( "SELECT request_id, status FROM logs ORDER BY " - + spend_management_endpoints._SESSION_REPRESENTATIVE_ORDER_SQL + + spend_management_endpoints.SESSION_REPRESENTATIVE_ORDER_SQL + " LIMIT 1" ).fetchone() assert result == ("parent", expected) connection.execute("DELETE FROM logs WHERE call_type = 'asend_message'") fallback = connection.execute( "SELECT request_id, status FROM logs ORDER BY " - + spend_management_endpoints._SESSION_REPRESENTATIVE_ORDER_SQL + + spend_management_endpoints.SESSION_REPRESENTATIVE_ORDER_SQL + " LIMIT 1" ).fetchone() assert fallback == ("llm", "success") diff --git a/tests/unit/proxy/spend_tracking/test_spend_query_optimization.py b/tests/unit/proxy/spend_tracking/test_spend_query_optimization.py index 93fae093340..7a38c1d8457 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_query_optimization.py +++ b/tests/unit/proxy/spend_tracking/test_spend_query_optimization.py @@ -6,7 +6,9 @@ GitHub Issue: #17487 """ import datetime +from collections.abc import Mapping, Sequence from datetime import timezone +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest @@ -508,20 +510,10 @@ async def test_global_spend_report_team_group_forwards_team_id(monkeypatch): @pytest.mark.asyncio async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch): - """ - With group_by_session=true, /spend/logs/ui must page and count SESSIONS, - not raw calls: the page is one representative row per session (DISTINCT - ON the session group key, preferring non-MCP calls, newest first), the - bounded count counts groups, and on the default startTime sort the page is - selected by keyset (no OFFSET) so deep pages do not degrade. Otherwise the - UI collapses a server page of N calls into fewer visible rows while the - footer still claims N (issue #38060). - """ + """With group_by_session=true, /spend/logs/ui pages and counts sessions, not calls, one representative row per + session, so the footer's total matches the rows shown (issue #38060).""" from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth - from litellm.proxy.spend_tracking.spend_management_endpoints import ( - SPEND_LOGS_PAGINATION_COUNT_CAP, - ui_view_spend_logs, - ) + from litellm.proxy.spend_tracking.spend_management_endpoints import ui_view_spend_logs group_key = "COALESCE(NULLIF(session_id, ''), request_id), api_key" session_rows = [ @@ -533,12 +525,12 @@ async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch): {"request_id": "req-2", "api_key": "k", "metadata": "{}", "session_id": None}, ] - async def mock_query_raw(sql_query, *params): - if "COUNT(*) AS total_count" in sql_query: - return [{"total_count": 60}] + async def mock_query_raw(sql_query: str, *params: object) -> Sequence[Mapping[str, object]]: + if "rows_read" in sql_query: + return [{**row, "settled": True, "rows_read": 0, "session_count": 60} for row in session_rows] if "AS session_representatives" in sql_query: return representative_rows - return session_rows + return [] mock_prisma = MagicMock() mock_prisma.db = MagicMock() @@ -565,22 +557,16 @@ async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch): user_api_key_dict=auth, group_by_session=True, session_cursor=None, + status_filter=None, ) emitted = [call[0] for call in mock_prisma.db.query_raw.call_args_list] page_sql = emitted[0][0] - assert f"GROUP BY {group_key}" in page_sql, f"page must select sessions, not calls. SQL was:\n{page_sql}" + assert "GROUP BY session_key, api_key" in page_sql, f"page must select sessions, not calls. SQL was:\n{page_sql}" assert "OFFSET" not in page_sql, "the startTime page must be keyset-selected, not offset-selected" - assert emitted[0][-1] == 51, "the page query fetches page_size + 1 sessions to detect has_more" + assert "LIMIT 51 " in page_sql, "the page read fetches page_size + 1 sessions to detect has_more" - count_call = emitted[1] - count_sql = count_call[0] - assert f"GROUP BY {group_key}" in count_sql, f"grouped total must count sessions. SQL was:\n{count_sql}" - assert "COUNT(*) OVER ()" not in count_sql - assert "LIMIT" in count_sql and "FROM (" in count_sql, "the grouped count must stay bounded" - assert count_call[-1] == SPEND_LOGS_PAGINATION_COUNT_CAP + 1 - - rep_sql = emitted[2][0] + rep_sql: Final = emitted[1][0] assert f"DISTINCT ON ({group_key})" in rep_sql, f"page must return one row per session. SQL was:\n{rep_sql}" assert ( f"ORDER BY {group_key}, (call_type = 'asend_message') DESC, " diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index c1c2593c4ef..f12280545b7 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1026,6 +1026,7 @@ _COST_MAP_ROW_SCHEMA: Final = { "cache_read_input_image_token_cost": {"type": "number"}, "audio_transcription_config": {"type": "string"}, "deprecation_date": {"type": "string"}, + "successor_model": {"type": "string"}, "input_cost_per_audio_per_second": {"type": "number"}, "input_cost_per_audio_per_second_above_128k_tokens": {"type": "number"}, "google_maps_grounding_cost_per_query": {"type": "number"}, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx index 6ad2f00fdb0..cd5c796b569 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx @@ -2,6 +2,7 @@ import { FilterInput } from "@/components/common_components/Filters/FilterInput" import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; import { Search, User } from "lucide-react"; +import { useTranslation } from "react-i18next"; interface OrganizationFiltersProps { filters: FilterState; @@ -23,6 +24,7 @@ const OrganizationFilters = ({ onChange, onReset, }: OrganizationFiltersProps) => { + const { t } = useTranslation(); const hasActiveFilters = !!(filters.org_id || filters.org_alias); return ( @@ -30,7 +32,7 @@ const OrganizationFilters = ({ {/* Search and Filter Controls */}
onChange("org_alias", value)} icon={Search} @@ -50,7 +52,7 @@ const OrganizationFilters = ({ {showFilters && (
onChange("org_id", value)} icon={User} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx index 2810902bef6..819aa6c3334 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -4,6 +4,7 @@ import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organization import { useQueryClient } from "@tanstack/react-query"; import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; import React, { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { toast } from "@/lib/toast"; import { organizationDeleteCall } from "@/components/networking"; @@ -28,6 +29,7 @@ const ORGANIZATION_DETAIL_STATE = { const ORGANIZATION_DETAIL_URL_KEYS = { tab: ORGANIZATION_TAB_URL_KEY }; const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { + const { t } = useTranslation(); const [{ org: selectedOrgId }, setOrganizationDetail] = useQueryStates(ORGANIZATION_DETAIL_STATE, { history: "push", urlKeys: ORGANIZATION_DETAIL_URL_KEYS, @@ -78,7 +80,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces try { setIsDeleting(true); await organizationDeleteCall(accessToken, orgToDelete); - toast.success("Organization deleted successfully"); + toast.success(t("organizations.panel.toasts.deleted")); setIsDeleteModalOpen(false); setOrgToDelete(null); @@ -99,16 +101,19 @@ const OrganizationsPanel: React.FC = ({ userRole, acces return (

- This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "} - - here - - . + + ), + }} + />

); @@ -118,7 +123,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces
{(userRole === "Admin" || userRole === "Org Admin") && ( )} @@ -133,7 +138,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces /> ) : ( <> -

Click on an organization ID to view its details.

+

{t("organizations.panel.listHint")}

= ({ userRole, acces @@ -27,12 +29,12 @@ function EmptyState({ searchActive }: { searchActive: boolean }) {
- {searchActive ? "No matching organizations" : "No organizations yet"} + {searchActive ? t("organizations.table.empty.noMatchesTitle") : t("organizations.table.empty.noneTitle")}
{searchActive - ? "No organizations match your search. Try a different name or ID." - : "Create an organization to group teams, models, and budgets."} + ? t("organizations.table.empty.noMatchesDescription") + : t("organizations.table.empty.noneDescription")}
); @@ -47,12 +49,13 @@ const OrganizationsTable: React.FC = ({ onEditClick, onDeleteClick, }) => { + const { t } = useTranslation(); const { sorting, onSortingChange, pagination, onPaginationChange } = useOrganizationsTableState(); const columns = useMemo(() => { - const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick }; + const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick, t }; return getOrganizationsTableColumns(deps); - }, [userRole, onOrganizationClick, onEditClick, onDeleteClick]); + }, [userRole, onOrganizationClick, onEditClick, onDeleteClick, t]); return ( = ({ sorting={sorting} onSortingChange={onSortingChange} isLoading={isLoading} - loadingMessage="Loading organizations…" + loadingMessage={t("organizations.table.loading")} noDataMessage={} size="compact" /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx index dcbf141314b..b4464392ea8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx @@ -1,7 +1,9 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import type { TFunction } from "i18next"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells"; @@ -25,11 +27,18 @@ const getOrganizationBudget = (organization: Organization): OrganizationBudget = (organization.litellm_budget_table ?? {}) as OrganizationBudget; function OrganizationLimitsCell({ organization }: { organization: Organization }) { + const { t } = useTranslation(); const { tpm_limit, rpm_limit } = getOrganizationBudget(organization); return (
- TPM: {tpm_limit ?? "Unlimited"} - RPM: {rpm_limit ?? "Unlimited"} + + {t("organizations.table.limits.tpmLabel")} + {tpm_limit ?? t("organizations.table.limits.unlimited")} + + + {t("organizations.table.limits.rpmLabel")} + {rpm_limit ?? t("organizations.table.limits.unlimited")} +
); } @@ -41,10 +50,11 @@ interface OrganizationRowActionsProps { } function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: OrganizationRowActionsProps) { + const { t } = useTranslation(); return ( @@ -56,7 +66,7 @@ function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: Or onClick={() => onEditClick(organization.organization_id)} > - Edit + {t("organizations.table.actions.edit")} onDeleteClick(organization.organization_id)} > - Delete + {t("organizations.table.actions.delete")} @@ -76,6 +86,7 @@ export interface OrganizationsTableColumnsDeps { onOrganizationClick: (organizationId: string) => void; onEditClick: (organizationId: string) => void; onDeleteClick: (organizationId: string) => void; + t: TFunction; } export const getOrganizationsTableColumns = ({ @@ -83,12 +94,13 @@ export const getOrganizationsTableColumns = ({ onOrganizationClick, onEditClick, onDeleteClick, + t, }: OrganizationsTableColumnsDeps): ColumnDef[] => [ { id: "organization_id", accessorKey: "organization_id", - meta: { title: "Organization ID" }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.id") }, + header: ({ column }) => , size: 220, enableSorting: true, cell: ({ row }) => ( @@ -103,8 +115,8 @@ export const getOrganizationsTableColumns = ({ { id: "organization_alias", accessorKey: "organization_alias", - meta: { title: "Organization Name" }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.name") }, + header: ({ column }) => , size: 200, enableSorting: true, cell: ({ row }) => { @@ -120,8 +132,8 @@ export const getOrganizationsTableColumns = ({ id: "created_at", accessorKey: "created_at", sortingFn: "datetime", - meta: { title: "Created" }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.created") }, + header: ({ column }) => , size: 130, enableSorting: true, cell: ({ row }) => , @@ -129,8 +141,8 @@ export const getOrganizationsTableColumns = ({ { id: "spend", accessorKey: "spend", - meta: { title: "Spend (USD)", numeric: true }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.spend"), numeric: true }, + header: ({ column }) => , size: 120, enableSorting: true, cell: ({ row }) => , @@ -140,12 +152,17 @@ export const getOrganizationsTableColumns = ({ accessorFn: (organization) => getOrganizationBudget(organization).max_budget ?? Number.POSITIVE_INFINITY, sortUndefined: "last", sortDescFirst: true, - meta: { title: "Budget (USD)", numeric: true }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.budget"), numeric: true }, + header: ({ column }) => , size: 120, enableSorting: true, cell: ({ row }) => ( - + ), }, { @@ -156,21 +173,21 @@ export const getOrganizationsTableColumns = ({ : undefined, sortUndefined: "last", sortDescFirst: false, - meta: { title: "Models", skeleton: "chips" }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.models"), skeleton: "chips" }, + header: ({ column }) => , size: 260, enableSorting: true, cell: ({ row }) => , }, { id: "limits", - meta: { title: "TPM / RPM Limits" }, + meta: { title: t("organizations.table.columns.limits") }, header: ({ table }) => ( ), @@ -183,11 +200,15 @@ export const getOrganizationsTableColumns = ({ accessorFn: (organization) => organization.members?.length ?? 0, sortUndefined: "last", sortDescFirst: true, - meta: { title: "Members", numeric: true }, - header: ({ column }) => , + meta: { title: t("organizations.table.columns.members"), numeric: true }, + header: ({ column }) => , size: 100, enableSorting: true, - cell: ({ row }) => {row.original.members?.length ?? 0} Members, + cell: ({ row }) => ( + + {t("organizations.table.memberCount", { count: row.original.members?.length ?? 0 })} + + ), }, ...(["tpm_limit", "rpm_limit"] as const).map( (field): ColumnDef => ({ @@ -201,7 +222,7 @@ export const getOrganizationsTableColumns = ({ { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, - header: () => Actions, + header: () => {t("organizations.table.columns.actions")}, size: 64, enableSorting: false, enableHiding: false, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx index 67a648dacd1..b38dd5c6d5e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx @@ -1,4 +1,5 @@ import React, { useId, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/components/networking"; import { UserEditView } from "./user_edit_view"; import { toast } from "@/lib/toast"; @@ -46,6 +47,7 @@ const BulkEditUserModal: React.FC = ({ userModels, allowAllUsers = false, }) => { + const { t } = useTranslation(); const { premiumUser } = useAuthorized(); const [loading, setLoading] = useState(false); const [selectedTeams, setSelectedTeams] = useState([]); @@ -89,7 +91,7 @@ const BulkEditUserModal: React.FC = ({ const handleSubmit = async (formValues: any) => { if (!accessToken) { - toast.fromError("Access token not found"); + toast.fromError(t("users.bulkEdit.toasts.noAccessToken")); return; } @@ -125,7 +127,7 @@ const BulkEditUserModal: React.FC = ({ const hasTeamAdditions = addToTeams && selectedTeams.length > 0; if (!hasUserUpdates && !hasTeamAdditions) { - toast.fromError("Please modify at least one field or select teams to add users to"); + toast.fromError(t("users.bulkEdit.toasts.nothingToUpdate")); return; } @@ -135,10 +137,10 @@ const BulkEditUserModal: React.FC = ({ if (hasUserUpdates) { if (updateAllUsers) { const result = await userBulkUpdateUserCall(accessToken, updatePayload, undefined, true); - successMessages.push(`Updated all users (${result.total_requested} total)`); + successMessages.push(t("users.bulkEdit.toasts.updatedAll", { total: result.total_requested })); } else { await userBulkUpdateUserCall(accessToken, updatePayload, userIds); - successMessages.push(`Updated ${userIds.length} user(s)`); + successMessages.push(t("users.bulkEdit.toasts.updated", { count: userIds.length })); } } @@ -190,11 +192,13 @@ const BulkEditUserModal: React.FC = ({ if (successfulTeams.length > 0) { const totalAdditions = successfulTeams.reduce((sum, r) => sum + r.successfulAdditions, 0); - successMessages.push(`Added users to ${successfulTeams.length} team(s) (${totalAdditions} total additions)`); + successMessages.push( + t("users.bulkEdit.toasts.addedToTeams", { count: successfulTeams.length, additions: totalAdditions }), + ); } if (failedTeams.length > 0) { - toast.warning(`Failed to add users to ${failedTeams.length} team(s)`); + toast.warning(t("users.bulkEdit.toasts.teamAddFailed", { count: failedTeams.length })); } } @@ -212,7 +216,7 @@ const BulkEditUserModal: React.FC = ({ onCancel(); } catch (error) { console.error("Bulk operation failed:", error); - toast.fromError("Failed to perform bulk operations"); + toast.fromError(t("users.bulkEdit.toasts.bulkFailed")); } finally { setLoading(false); } @@ -223,7 +227,7 @@ const BulkEditUserModal: React.FC = ({ - {updateAllUsers ? "Bulk Edit All Users" : `Bulk Edit ${selectedUsers.length} User(s)`} + {updateAllUsers ? t("users.bulkEdit.titleAll") : t("users.bulkEdit.title", { count: selectedUsers.length })} {allowAllUsers && ( @@ -233,17 +237,15 @@ const BulkEditUserModal: React.FC = ({ id={updateAllUsersId} checked={updateAllUsers} onCheckedChange={(checked) => setUpdateAllUsers(checked === true)} - aria-label="Update ALL users in the system" + aria-label={t("users.bulkEdit.updateAll")} />
{updateAllUsers && (
- - ⚠️ This will apply changes to ALL users in the system, not just the selected ones. - + {t("users.bulkEdit.updateAllWarning")}
)} @@ -251,15 +253,19 @@ const BulkEditUserModal: React.FC = ({ {!updateAllUsers && (
-
Selected Users ({selectedUsers.length}):
+
+ {t("users.bulkEdit.selectedUsers", { count: selectedUsers.length })} +
- User ID - Email - Current Role - Budget + {t("users.bulkEdit.table.userId")} + {t("users.bulkEdit.table.email")} + {t("users.bulkEdit.table.currentRole")} + + {t("users.bulkEdit.table.budget")} + @@ -268,12 +274,19 @@ const BulkEditUserModal: React.FC = ({ {user.user_id.length > 20 ? `${user.user_id.slice(0, 20)}...` : user.user_id} - {user.user_email || "No email"} + + {user.user_email || t("users.bulkEdit.table.noEmail")} + {possibleUIRoles?.[user.user_role]?.ui_label || user.user_role} - + ))} @@ -287,15 +300,14 @@ const BulkEditUserModal: React.FC = ({

- Instructions: Fill in the fields below with the values you want to apply to all selected - users. You can bulk edit: role, budget, models, and metadata. You can also add users to teams. + }} />

{/* Team Management Section */} - Team Management + {t("users.bulkEdit.teamManagement.title")}
@@ -304,10 +316,10 @@ const BulkEditUserModal: React.FC = ({ id={addToTeamsId} checked={addToTeams} onCheckedChange={(checked) => setAddToTeams(checked === true)} - aria-label="Add selected users to teams" + aria-label={t("users.bulkEdit.teamManagement.addToTeams")} />
@@ -315,12 +327,12 @@ const BulkEditUserModal: React.FC = ({ <>
= ({
) => setTeamBudget(event.target.value === "" ? null : Number(event.target.value)) @@ -348,13 +360,12 @@ const BulkEditUserModal: React.FC = ({ step={0.01} /> - Leave empty for unlimited budget within team limits + {t("users.bulkEdit.teamManagement.teamBudgetHint")}
- Users will be added with "user" role by default. All users will be added to each selected - team. + {t("users.bulkEdit.teamManagement.defaultRoleNote")} )} @@ -379,7 +390,9 @@ const BulkEditUserModal: React.FC = ({ {loading && (
- Updating {updateAllUsers ? "all users" : selectedUsers.length} user(s)... + {updateAllUsers + ? t("users.bulkEdit.updatingAll") + : t("users.bulkEdit.updating", { count: selectedUsers.length })}
)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx index 0239844851f..a617d5f84cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx @@ -1,8 +1,10 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ParseKeys } from "i18next"; import * as React from "react"; import { useFieldArray, type Control } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect"; @@ -29,21 +31,21 @@ import { const NO_RESET = "never"; -const BUDGET_DURATION_OPTIONS = [ - { value: NO_RESET, label: "No reset" }, - { value: "1h", label: "hourly" }, - { value: "24h", label: "daily" }, - { value: "7d", label: "weekly" }, - { value: "30d", label: "monthly" }, -] as const; +const BUDGET_DURATION_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [ + { value: NO_RESET, labelKey: "users.defaultSettings.budgetDurations.noReset" }, + { value: "1h", labelKey: "users.defaultSettings.budgetDurations.hourly" }, + { value: "24h", labelKey: "users.defaultSettings.budgetDurations.daily" }, + { value: "7d", labelKey: "users.defaultSettings.budgetDurations.weekly" }, + { value: "30d", labelKey: "users.defaultSettings.budgetDurations.monthly" }, +]; -const TEAM_ROLE_OPTIONS = [ - { value: "user", label: "User" }, - { value: "admin", label: "Admin" }, -] as const; +const TEAM_ROLE_OPTIONS: readonly { value: "user" | "admin"; labelKey: ParseKeys }[] = [ + { value: "user", labelKey: "users.defaultSettings.teamRoles.user" }, + { value: "admin", labelKey: "users.defaultSettings.teamRoles.admin" }, +]; -const MODEL_SENTINEL_LABELS: ReadonlyMap = new Map( - MODEL_SENTINEL_OPTIONS.map(({ value, label }) => [value, label]), +const MODEL_SENTINEL_LABEL_KEYS: ReadonlyMap = new Map( + MODEL_SENTINEL_OPTIONS.map(({ value, labelKey }) => [value, labelKey]), ); const TEAMS_PAGE_SIZE = 50; @@ -71,6 +73,7 @@ interface RoleOption { type SettingsControl = Control; const TeamPickerField = ({ control, index }: { control: SettingsControl; index: number }) => { + const { t } = useTranslation(); const [search, setSearch] = React.useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( TEAMS_PAGE_SIZE, @@ -90,7 +93,7 @@ const TeamPickerField = ({ control, index }: { control: SettingsControl; index: ); return ( - + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( { + const { t } = useTranslation(); const { fields, append, remove } = useFieldArray({ control, name: "teams" }); + const teamRoleItems = TEAM_ROLE_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) })); return (
-

Default Teams

-

- New users are added to these teams. Only teams that already exist can be selected. -

+

{t("users.defaultSettings.fields.teams")}

+

{t("users.defaultSettings.teams.description")}

{fields.map((field, index) => (
-

Team {index + 1}

+

{t("users.defaultSettings.teams.rowTitle", { number: index + 1 })}

- + {({ ref, ...budgetField }) => ( - + )} - + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - onChange(selected ?? "user")}> { - {TEAM_ROLE_OPTIONS.map((option) => ( + {teamRoleItems.map((option) => ( {option.label} @@ -172,7 +186,7 @@ const TeamsField = ({ control }: { control: SettingsControl }) => { ))}
); @@ -191,30 +205,41 @@ interface SettingsViewProps { } const SettingsView = ({ values, roleOptions }: SettingsViewProps) => { + const { t } = useTranslation(); const roleLabel = roleOptions.find((option) => option.value === values.user_role)?.label ?? values.user_role; const durationValue = values.budget_duration === "" ? NO_RESET : values.budget_duration; - const durationLabel = - BUDGET_DURATION_OPTIONS.find((option) => option.value === durationValue)?.label ?? values.budget_duration; + const durationOption = BUDGET_DURATION_OPTIONS.find((option) => option.value === durationValue); + const durationLabel = durationOption === undefined ? values.budget_duration : t(durationOption.labelKey); + const notSet = t("users.defaultSettings.view.notSet"); return (
- {roleLabel === "" ? "Not set" : roleLabel} - {values.max_budget === "" ? "Not set" : values.max_budget} - {durationLabel} - + {roleLabel === "" ? notSet : roleLabel} + + {values.max_budget === "" ? notSet : values.max_budget} + + {durationLabel} + {values.models.length === 0 - ? "Not set" - : values.models.map((model) => MODEL_SENTINEL_LABELS.get(model) ?? model).join(", ")} + ? notSet + : values.models + .map((model) => { + const labelKey = MODEL_SENTINEL_LABEL_KEYS.get(model); + return labelKey === undefined ? model : t(labelKey); + }) + .join(", ")}
-

Default Teams

+

{t("users.defaultSettings.fields.teams")}

{values.teams.length === 0 ? ( -

None

+

{t("users.defaultSettings.view.none")}

) : ( values.teams.map((team) => (

{team.team_id} - {team.max_budget_in_team !== "" && <> · ${team.max_budget_in_team} max budget} + {team.max_budget_in_team !== "" && ( + <> · {t("users.defaultSettings.view.teamMaxBudget", { budget: team.max_budget_in_team })} + )} <> · {team.user_role}

)) @@ -233,20 +258,25 @@ interface SettingsFormProps { } const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, onSaved }: SettingsFormProps) => { + const { t } = useTranslation(); const queryClient = useQueryClient(); + const budgetDurationItems = BUDGET_DURATION_OPTIONS.map((option) => ({ + value: option.value, + label: t(option.labelKey), + })); const form = useZodForm(defaultUserSettingsSchema, { defaultValues: initialValues }); const { isDirty } = form.formState; const mutation = useMutation({ mutationFn: (values: DefaultUserSettingsSubmitValues) => updateSettings(buildBody(values)), onSuccess: (_result, values) => { - toast.success("Default user settings updated successfully"); + toast.success(t("users.defaultSettings.toasts.updated")); queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY }); form.reset(values); onSaved(); }, onError: (error: unknown) => - toast.fromError(error instanceof Error ? error.message : "Failed to update default user settings"), + toast.fromError(error instanceof Error ? error.message : t("users.defaultSettings.toasts.updateFailed")), }); const onSubmit = form.handleSubmit((values) => mutation.mutate(values)); @@ -257,8 +287,8 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( } @@ -295,12 +325,12 @@ const SettingsForm = ({ initialValues, roleOptions, updateSettings, onCancel, on {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( } )} {!isBulkEdit && ( - + {({ ref, value, ...control }) => } )} - + {({ ref, value, ...control }) => } @@ -242,8 +247,8 @@ export function UserEditView({ control={form.control} name="user_role" label={labelWithHint( - "Global Proxy Role", - "This is the role that the user will globally on the proxy. This role is independent of any team/org specific roles.", + t("users.editView.fields.globalProxyRole"), + t("users.editView.fields.globalProxyRoleHint"), )} > {({ id, value, onChange }) => ( @@ -271,8 +276,8 @@ export function UserEditView({ control={form.control} name="models" label={labelWithHint( - "Personal Models", - "Select which models this user can access outside of team-scope. Choose 'All Proxy Models' to grant access to all models available on the proxy.", + t("users.editView.fields.personalModels"), + t("users.editView.fields.personalModelsHint"), )} > {({ value, onChange }) => ( @@ -280,7 +285,7 @@ export function UserEditView({ options={modelOptions} value={value} onValueChange={onChange} - placeholder="Select models" + placeholder={t("users.editView.fields.modelsPlaceholder")} disabled={!all_admin_roles.includes(userRole || "")} /> )} @@ -291,10 +296,10 @@ export function UserEditView({ name="max_budget" label={ <> - Max Budget (USD) + {t("users.editView.fields.maxBudget")} } @@ -308,19 +313,19 @@ export function UserEditView({ value={value ?? ""} onChange={(event) => onChange(event.target.value)} onWheel={(event) => event.currentTarget.blur()} - placeholder="Enter a numerical value" + placeholder={t("users.editView.fields.maxBudgetPlaceholder")} disabled={unlimitedBudget} /> )} - + {({ id, value, onChange }) => ( )} @@ -330,10 +335,7 @@ export function UserEditView({ {({ ref, value, onChange, ...control }) => ( onChange(event.target.value)} onWheel={(event) => event.currentTarget.blur()} - placeholder="Unlimited" + placeholder={t("users.editView.fields.rateLimitPlaceholder")} /> )} @@ -353,10 +355,7 @@ export function UserEditView({ {({ ref, value, onChange, ...control }) => ( onChange(event.target.value)} onWheel={(event) => event.currentTarget.blur()} - placeholder="Unlimited" + placeholder={t("users.editView.fields.rateLimitPlaceholder")} /> )} @@ -385,13 +384,19 @@ export function UserEditView({ onChange={setModelMaxBudget} availableModels={userModels} usage={userData.user_info?.model_max_budget_usage} - hint="Cap this user's spend on individual models, each with its own reset window. Applies across every key the user holds." + hint={t("users.editView.fields.modelMaxBudgetHint")} /> )} - + {({ ref, value, ...control }) => ( -