Skip to content

Commit 3914226

Browse files
authored
Merge pull request #25796 from BerriAI/litellm_yj_apr14
[Infra] Merge dev branch
2 parents 2f72eb6 + 42ab3f9 commit 3914226

13 files changed

Lines changed: 945 additions & 75 deletions

File tree

docs/my-website/docs/proxy/config_settings.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,8 @@ router_settings:
804804
| LITELLM_ASSETS_PATH | Path to directory for UI assets and logos. Used when running with read-only filesystem (e.g., Kubernetes). Default is `/var/lib/litellm/assets` in Docker.
805805
| LITELLM_BLOG_POSTS_URL | Custom URL for fetching LiteLLM blog posts JSON. Default is the GitHub main branch URL
806806
| LITELLM_CLI_JWT_EXPIRATION_HOURS | Expiration time in hours for CLI-generated JWT tokens. Default is 24 hours
807+
| LITELLM_CORS_ALLOW_CREDENTIALS | Set to `true` to explicitly allow credentials in CORS responses. When not set, credentials are disabled automatically if `LITELLM_CORS_ORIGINS` is `*` (wildcard) to prevent the browser security misconfiguration of reflecting any origin with credentials
808+
| LITELLM_CORS_ORIGINS | Comma-separated list of allowed CORS origins (e.g. `https://app.example.com,https://admin.example.com`). Defaults to `*` (all origins) when not set
807809
| LITELLM_DD_AGENT_HOST | Hostname or IP of DataDog agent for LiteLLM-specific logging. When set, logs are sent to agent instead of direct API
808810
| LITELLM_DEPLOYMENT_ENVIRONMENT | Environment name for the deployment (e.g., "production", "staging"). Used as a fallback when OTEL_ENVIRONMENT_NAME is not set. Sets the `environment` tag in telemetry data
809811
| LITELLM_DETAILED_TIMING | When true, adds detailed per-phase timing headers to responses (`x-litellm-timing-{pre-processing,llm-api,post-processing,message-copy}-ms`). Default is false. See [latency overhead docs](../troubleshoot/latency_overhead.md)

litellm/proxy/db/create_views.py

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
_db = Any
66

7+
# Markers that indicate a view/relation does not yet exist in the database.
8+
# Keeping these in one place avoids repeating the check across all view blocks
9+
# and prevents overly broad matches (e.g. bare 'undefined' would also match
10+
# 'undefined function' or 'column undefined_col referenced in query').
11+
_VIEW_NOT_FOUND_MARKERS = ("does not exist", "no such table", "undefined table")
12+
713

814
async def create_missing_views(db: _db): # noqa: PLR0915
915
"""
@@ -18,14 +24,17 @@ async def create_missing_views(db: _db): # noqa: PLR0915
1824
1925
If the view doesn't exist, one will be created.
2026
"""
27+
2128
try:
2229
# Try to select one row from the view
2330
await db.query_raw("""SELECT 1 FROM "LiteLLM_VerificationTokenView" LIMIT 1""")
24-
print("LiteLLM_VerificationTokenView Exists!") # noqa
25-
except Exception:
31+
verbose_logger.debug("LiteLLM_VerificationTokenView Exists!")
32+
except Exception as e:
33+
error_msg = str(e).lower()
34+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
35+
raise
2636
# If an error occurs, the view does not exist, so create it
27-
await db.execute_raw(
28-
"""
37+
await db.execute_raw("""
2938
CREATE VIEW "LiteLLM_VerificationTokenView" AS
3039
SELECT
3140
v.*,
@@ -37,15 +46,17 @@ async def create_missing_views(db: _db): # noqa: PLR0915
3746
FROM "LiteLLM_VerificationToken" v
3847
LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id
3948
LEFT JOIN "LiteLLM_ProjectTable" p ON v.project_id = p.project_id;
40-
"""
41-
)
49+
""")
4250

43-
print("LiteLLM_VerificationTokenView Created!") # noqa
51+
verbose_logger.debug("LiteLLM_VerificationTokenView Created!")
4452

4553
try:
4654
await db.query_raw("""SELECT 1 FROM "MonthlyGlobalSpend" LIMIT 1""")
47-
print("MonthlyGlobalSpend Exists!") # noqa
48-
except Exception:
55+
verbose_logger.debug("MonthlyGlobalSpend Exists!")
56+
except Exception as e:
57+
error_msg = str(e).lower()
58+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
59+
raise
4960
sql_query = """
5061
CREATE OR REPLACE VIEW "MonthlyGlobalSpend" AS
5162
SELECT
@@ -60,12 +71,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
6071
"""
6172
await db.execute_raw(query=sql_query)
6273

63-
print("MonthlyGlobalSpend Created!") # noqa
74+
verbose_logger.debug("MonthlyGlobalSpend Created!")
6475

6576
try:
6677
await db.query_raw("""SELECT 1 FROM "Last30dKeysBySpend" LIMIT 1""")
67-
print("Last30dKeysBySpend Exists!") # noqa
68-
except Exception:
78+
verbose_logger.debug("Last30dKeysBySpend Exists!")
79+
except Exception as e:
80+
error_msg = str(e).lower()
81+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
82+
raise
6983
sql_query = """
7084
CREATE OR REPLACE VIEW "Last30dKeysBySpend" AS
7185
SELECT
@@ -88,12 +102,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
88102
"""
89103
await db.execute_raw(query=sql_query)
90104

91-
print("Last30dKeysBySpend Created!") # noqa
105+
verbose_logger.debug("Last30dKeysBySpend Created!")
92106

93107
try:
94108
await db.query_raw("""SELECT 1 FROM "Last30dModelsBySpend" LIMIT 1""")
95-
print("Last30dModelsBySpend Exists!") # noqa
96-
except Exception:
109+
verbose_logger.debug("Last30dModelsBySpend Exists!")
110+
except Exception as e:
111+
error_msg = str(e).lower()
112+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
113+
raise
97114
sql_query = """
98115
CREATE OR REPLACE VIEW "Last30dModelsBySpend" AS
99116
SELECT
@@ -111,11 +128,14 @@ async def create_missing_views(db: _db): # noqa: PLR0915
111128
"""
112129
await db.execute_raw(query=sql_query)
113130

114-
print("Last30dModelsBySpend Created!") # noqa
131+
verbose_logger.debug("Last30dModelsBySpend Created!")
115132
try:
116133
await db.query_raw("""SELECT 1 FROM "MonthlyGlobalSpendPerKey" LIMIT 1""")
117-
print("MonthlyGlobalSpendPerKey Exists!") # noqa
118-
except Exception:
134+
verbose_logger.debug("MonthlyGlobalSpendPerKey Exists!")
135+
except Exception as e:
136+
error_msg = str(e).lower()
137+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
138+
raise
119139
sql_query = """
120140
CREATE OR REPLACE VIEW "MonthlyGlobalSpendPerKey" AS
121141
SELECT
@@ -132,13 +152,16 @@ async def create_missing_views(db: _db): # noqa: PLR0915
132152
"""
133153
await db.execute_raw(query=sql_query)
134154

135-
print("MonthlyGlobalSpendPerKey Created!") # noqa
155+
verbose_logger.debug("MonthlyGlobalSpendPerKey Created!")
136156
try:
137157
await db.query_raw(
138158
"""SELECT 1 FROM "MonthlyGlobalSpendPerUserPerKey" LIMIT 1"""
139159
)
140-
print("MonthlyGlobalSpendPerUserPerKey Exists!") # noqa
141-
except Exception:
160+
verbose_logger.debug("MonthlyGlobalSpendPerUserPerKey Exists!")
161+
except Exception as e:
162+
error_msg = str(e).lower()
163+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
164+
raise
142165
sql_query = """
143166
CREATE OR REPLACE VIEW "MonthlyGlobalSpendPerUserPerKey" AS
144167
SELECT
@@ -157,12 +180,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
157180
"""
158181
await db.execute_raw(query=sql_query)
159182

160-
print("MonthlyGlobalSpendPerUserPerKey Created!") # noqa
183+
verbose_logger.debug("MonthlyGlobalSpendPerUserPerKey Created!")
161184

162185
try:
163186
await db.query_raw("""SELECT 1 FROM "DailyTagSpend" LIMIT 1""")
164-
print("DailyTagSpend Exists!") # noqa
165-
except Exception:
187+
verbose_logger.debug("DailyTagSpend Exists!")
188+
except Exception as e:
189+
error_msg = str(e).lower()
190+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
191+
raise
166192
sql_query = """
167193
CREATE OR REPLACE VIEW "DailyTagSpend" AS
168194
SELECT
@@ -175,12 +201,15 @@ async def create_missing_views(db: _db): # noqa: PLR0915
175201
"""
176202
await db.execute_raw(query=sql_query)
177203

178-
print("DailyTagSpend Created!") # noqa
204+
verbose_logger.debug("DailyTagSpend Created!")
179205

180206
try:
181207
await db.query_raw("""SELECT 1 FROM "Last30dTopEndUsersSpend" LIMIT 1""")
182-
print("Last30dTopEndUsersSpend Exists!") # noqa
183-
except Exception:
208+
verbose_logger.debug("Last30dTopEndUsersSpend Exists!")
209+
except Exception as e:
210+
error_msg = str(e).lower()
211+
if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS):
212+
raise
184213
sql_query = """
185214
CREATE VIEW "Last30dTopEndUsersSpend" AS
186215
SELECT end_user, COUNT(*) AS total_events, SUM(spend) AS total_spend
@@ -193,7 +222,7 @@ async def create_missing_views(db: _db): # noqa: PLR0915
193222
"""
194223
await db.execute_raw(query=sql_query)
195224

196-
print("Last30dTopEndUsersSpend Created!") # noqa
225+
verbose_logger.debug("Last30dTopEndUsersSpend Created!")
197226

198227
return
199228

litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def _delete_old_logs(
8282
break
8383
# Step 1: Find logs and delete them in one go without fetching to application
8484
# Delete in batches, limited by self.batch_size
85-
deleted_count = await prisma_client.db.execute_raw(
85+
deleted_result = await prisma_client.db.execute_raw(
8686
"""
8787
DELETE FROM "LiteLLM_SpendLogs"
8888
WHERE "request_id" IN (
@@ -94,6 +94,17 @@ async def _delete_old_logs(
9494
cutoff_date,
9595
self.batch_size,
9696
)
97+
98+
deleted_count = 0
99+
if isinstance(deleted_result, int):
100+
deleted_count = deleted_result
101+
else:
102+
verbose_proxy_logger.error(
103+
f"Unexpected execute_raw return type for spend log cleanup: {type(deleted_result)}; "
104+
"aborting cleanup to avoid infinite loop"
105+
)
106+
break
107+
97108
verbose_proxy_logger.info(f"Deleted {deleted_count} logs in this batch")
98109

99110
if deleted_count == 0:

litellm/proxy/management_helpers/team_member_permission_checks.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,20 @@ async def can_team_member_execute_key_management_endpoint(
9898
)
9999

100100
# 5. Check if the team member has permissions for the endpoint
101-
TeamMemberPermissionChecks.does_team_member_have_permissions_for_endpoint(
102-
team_member_object=key_assigned_user_in_team,
103-
team_table=team_table,
104-
route=route,
101+
has_permission = (
102+
TeamMemberPermissionChecks.does_team_member_have_permissions_for_endpoint(
103+
team_member_object=key_assigned_user_in_team,
104+
team_table=team_table,
105+
route=route,
106+
)
105107
)
108+
if not has_permission:
109+
raise ProxyException(
110+
message=f"User {user_api_key_dict.user_id} does not belong to team {team_table.team_id}. Team-scoped key management endpoints can only be used for keys in your own team.",
111+
type=ProxyErrorTypes.team_member_permission_error,
112+
param=route,
113+
code=401,
114+
)
106115

107116
@staticmethod
108117
def does_team_member_have_permissions_for_endpoint(

litellm/proxy/proxy_server.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
LITELLM_SETTINGS_SAFE_DB_OVERRIDES,
5555
LITELLM_UI_ALLOW_HEADERS,
5656
LITELLM_UI_SESSION_DURATION,
57-
DAILY_TAG_SPEND_BATCH_MULTIPLIER
57+
DAILY_TAG_SPEND_BATCH_MULTIPLIER,
5858
)
5959
from litellm.litellm_core_utils.litellm_logging import (
6060
_init_custom_logger_compatible_class,
@@ -1139,7 +1139,54 @@ async def openai_exception_handler(request: Request, exc: ProxyException):
11391139

11401140

11411141
router = APIRouter()
1142-
origins = ["*"]
1142+
1143+
1144+
def _get_cors_config(
1145+
cors_origins_env: Optional[str] = None,
1146+
cors_credentials_env: Optional[str] = None,
1147+
):
1148+
"""
1149+
Compute CORS allowed origins and credentials flag from environment variables.
1150+
1151+
Extracted into a function so it can be unit-tested without reloading the module.
1152+
1153+
Args:
1154+
cors_origins_env: Value of LITELLM_CORS_ORIGINS (defaults to os.getenv).
1155+
cors_credentials_env: Value of LITELLM_CORS_ALLOW_CREDENTIALS (defaults to os.getenv).
1156+
1157+
Returns:
1158+
Tuple[List[str], bool]: (origins, allow_credentials)
1159+
"""
1160+
_origins_raw = (
1161+
cors_origins_env
1162+
if cors_origins_env is not None
1163+
else os.getenv("LITELLM_CORS_ORIGINS")
1164+
)
1165+
if _origins_raw is None or _origins_raw.strip() == "":
1166+
computed_origins = ["*"]
1167+
else:
1168+
computed_origins = [o.strip() for o in _origins_raw.split(",") if o.strip()]
1169+
1170+
# Disable credentials by default when wildcard origins are used — combining
1171+
# allow_origins=["*"] with allow_credentials=True causes Starlette to reflect
1172+
# the incoming Origin header, allowing any site to make credentialed requests.
1173+
# Set LITELLM_CORS_ALLOW_CREDENTIALS=true to explicitly restore the old behaviour
1174+
# (e.g. for non-browser clients that relied on the Access-Control-Allow-Credentials
1175+
# header being present regardless of origin).
1176+
_credentials_raw = (
1177+
cors_credentials_env
1178+
if cors_credentials_env is not None
1179+
else os.getenv("LITELLM_CORS_ALLOW_CREDENTIALS")
1180+
)
1181+
if _credentials_raw is not None:
1182+
computed_credentials = _credentials_raw.strip().lower() == "true"
1183+
else:
1184+
computed_credentials = "*" not in computed_origins
1185+
1186+
return computed_origins, computed_credentials
1187+
1188+
1189+
origins, allow_cors_credentials = _get_cors_config()
11431190

11441191

11451192
# get current directory
@@ -1466,7 +1513,7 @@ def _restructure_ui_html_files(ui_root: str) -> None:
14661513
app.add_middleware(
14671514
CORSMiddleware,
14681515
allow_origins=origins,
1469-
allow_credentials=True,
1516+
allow_credentials=allow_cors_credentials,
14701517
allow_methods=["*"],
14711518
allow_headers=["*"],
14721519
expose_headers=LITELLM_UI_ALLOW_HEADERS,
@@ -2315,9 +2362,13 @@ def _write_health_state_to_router_cache(
23152362

23162363
exception_status = getattr(original_exception, "status_code", 500)
23172364

2318-
if llm_router.health_check_ignore_transient_errors and exception_status in (
2319-
429,
2320-
408,
2365+
if (
2366+
llm_router.health_check_ignore_transient_errors
2367+
and exception_status
2368+
in (
2369+
429,
2370+
408,
2371+
)
23212372
):
23222373
continue
23232374

@@ -6286,7 +6337,9 @@ async def initialize_scheduled_background_jobs( # noqa: PLR0915
62866337

62876338
### UPDATE DAILY TAG SPEND (separate scheduler job with longer interval) ###
62886339
## Reduces QPS as there are more tags for a single request
6289-
tag_spend_update_interval = int(batch_writing_interval * DAILY_TAG_SPEND_BATCH_MULTIPLIER)
6340+
tag_spend_update_interval = int(
6341+
batch_writing_interval * DAILY_TAG_SPEND_BATCH_MULTIPLIER
6342+
)
62906343
from litellm.proxy.utils import update_daily_tag_spend
62916344

62926345
scheduler.add_job(
@@ -7131,9 +7184,9 @@ async def chat_completion( # noqa: PLR0915
71317184
hasattr(user_api_key_dict, "organization_alias")
71327185
and user_api_key_dict.organization_alias is not None
71337186
):
7134-
data["metadata"]["user_api_key_org_alias"] = (
7135-
user_api_key_dict.organization_alias
7136-
)
7187+
data["metadata"][
7188+
"user_api_key_org_alias"
7189+
] = user_api_key_dict.organization_alias
71377190
if (
71387191
hasattr(user_api_key_dict, "agent_id")
71397192
and user_api_key_dict.agent_id is not None
@@ -7312,9 +7365,9 @@ async def completion( # noqa: PLR0915
73127365
hasattr(user_api_key_dict, "organization_alias")
73137366
and user_api_key_dict.organization_alias is not None
73147367
):
7315-
data["metadata"]["user_api_key_org_alias"] = (
7316-
user_api_key_dict.organization_alias
7317-
)
7368+
data["metadata"][
7369+
"user_api_key_org_alias"
7370+
] = user_api_key_dict.organization_alias
73187371
if (
73197372
hasattr(user_api_key_dict, "agent_id")
73207373
and user_api_key_dict.agent_id is not None
@@ -7561,9 +7614,9 @@ async def embeddings( # noqa: PLR0915
75617614
hasattr(user_api_key_dict, "organization_alias")
75627615
and user_api_key_dict.organization_alias is not None
75637616
):
7564-
data["metadata"]["user_api_key_org_alias"] = (
7565-
user_api_key_dict.organization_alias
7566-
)
7617+
data["metadata"][
7618+
"user_api_key_org_alias"
7619+
] = user_api_key_dict.organization_alias
75677620
if (
75687621
hasattr(user_api_key_dict, "agent_id")
75697622
and user_api_key_dict.agent_id is not None

0 commit comments

Comments
 (0)