fix: Handle LiteLLM v1.80+ 404 response for new users (#12250)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-01-02 17:18:47 -05:00
committed by GitHub
parent cdc42130e1
commit 2ebde2529d
2 changed files with 87 additions and 5 deletions

View File

@@ -285,14 +285,21 @@ class SaasSettingsStore(SettingsStore):
'x-goog-api-key': LITE_LLM_API_KEY,
},
) as client:
# Get the previous max budget to prevent accidental loss
# In Litellm a get always succeeds, regardless of whether the user actually exists
# Get the previous max budget to prevent accidental loss.
#
# LiteLLM v1.80+ returns 404 for non-existent users (previously returned empty user_info)
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
)
response.raise_for_status()
response_json = response.json()
user_info = response_json.get('user_info') or {}
user_info: dict
if response.status_code == 404:
# New user - doesn't exist in LiteLLM yet (v1.80+ behavior)
user_info = {}
else:
# For any other status, use standard error handling
response.raise_for_status()
response_json = response.json()
user_info = response_json.get('user_info') or {}
logger.info(
f'creating_litellm_user: {self.user_id}; prev_max_budget: {user_info.get("max_budget")}; prev_metadata: {user_info.get("metadata")}'
)

View File

@@ -1,5 +1,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pydantic import SecretStr
from server.constants import (
@@ -335,6 +336,80 @@ async def test_update_settings_with_litellm_default_error(settings_store):
assert settings is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'status_code,user_info_response,should_succeed',
[
# 200 OK with user info - existing user (v1.79.x and v1.80+ behavior)
(200, {'user_info': {'max_budget': 10, 'spend': 5}}, True),
# 200 OK with empty user info - new user (v1.79.x behavior)
(200, {'user_info': None}, True),
# 404 Not Found - new user (v1.80+ behavior)
(404, None, True),
# 500 Internal Server Error - should fail
(500, None, False),
],
)
async def test_update_settings_with_litellm_default_handles_user_info_responses(
settings_store, session_maker, status_code, user_info_response, should_succeed
):
"""Test that various LiteLLM user/info responses are handled correctly.
LiteLLM API behavior changed between versions:
- v1.79.x and earlier: GET /user/info always succeeds with empty user_info
- v1.80.x and later: GET /user/info returns 404 for non-existent users
"""
mock_get_response = MagicMock()
mock_get_response.status_code = status_code
if user_info_response is not None:
mock_get_response.json = MagicMock(return_value=user_info_response)
mock_get_response.raise_for_status = MagicMock()
else:
mock_get_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
'Error', request=MagicMock(), response=mock_get_response
)
if status_code >= 500
else None
)
# Mock successful responses for POST operations (delete and create)
mock_post_response = MagicMock()
mock_post_response.is_success = True
mock_post_response.json = MagicMock(return_value={'key': 'new_user_api_key'})
with (
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'test_key'),
patch('storage.saas_settings_store.LITE_LLM_API_URL', 'http://test.url'),
patch('storage.saas_settings_store.LITE_LLM_TEAM_ID', 'test_team'),
patch(
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testuser@example.com'}),
),
patch('httpx.AsyncClient') as mock_client,
patch('storage.saas_settings_store.session_maker', session_maker),
):
# Set up the mock client
mock_client.return_value.__aenter__.return_value.get.return_value = (
mock_get_response
)
mock_client.return_value.__aenter__.return_value.post.return_value = (
mock_post_response
)
settings = Settings()
if should_succeed:
settings = await settings_store.update_settings_with_litellm_default(
settings
)
assert settings is not None
assert settings.llm_api_key is not None
assert settings.llm_api_key.get_secret_value() == 'new_user_api_key'
else:
with pytest.raises(httpx.HTTPStatusError):
await settings_store.update_settings_with_litellm_default(settings)
@pytest.mark.asyncio
async def test_update_settings_with_litellm_retry_on_duplicate_email(
settings_store, mock_litellm_api, session_maker