diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index c6e92e7d802..a953dd89431 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -1,4 +1,5 @@ import asyncio +import base64 import copy import importlib.metadata import json @@ -13,7 +14,6 @@ from typing import Any, Dict, Iterable, Literal, Optional, Union, cast import fastapi import yaml from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from pydantic import BaseModel, Field import litellm from litellm._logging import verbose_logger, verbose_proxy_logger @@ -41,6 +41,7 @@ from litellm.proxy.health_check import ( perform_health_check, run_with_timeout, ) +from litellm.proxy.health_endpoints.diagnose_types import DiagnoseRequest from litellm.proxy.middleware.in_flight_requests_middleware import ( get_in_flight_requests, ) @@ -138,27 +139,10 @@ services = Union[ ] _DIAGNOSE_REDACTED_VALUE = "[REDACTED]" +_DIAGNOSE_LLM_TIMEOUT_SECONDS = 30 _diagnose_sensitive_masker = SensitiveDataMasker() -class DiagnoseRequest(BaseModel): - model: Optional[str] = Field( - default=None, - description=( - "Optional proxy model name to use for generating the diagnostic report. " - "When omitted, LiteLLM uses the first configured proxy deployment." - ), - ) - issue_description: Optional[str] = Field( - default=None, - description="Short description of the issue the admin is trying to reproduce.", - ) - reproduction_steps: Optional[str] = Field( - default=None, - description="Known reproduction steps or the behavior the admin has already tried.", - ) - - def _redact_diagnose_payload(payload: Any, sensitive_parent: bool = False) -> Any: if isinstance(payload, dict): redacted: dict = {} @@ -223,19 +207,97 @@ def _build_diagnose_response( selected_model: Optional[str], diagnostic_report: str, diagnostic_context: dict, + next_question: Optional[str] = None, + next_question_index: Optional[int] = None, + next_request_body: Optional[dict] = None, + next_curl: Optional[str] = None, ) -> dict: + response_context = { + key: value + for key, value in diagnostic_context.items() + if key != "diagnostic_questions" + } return { "used_llm": used_llm, "selected_model": selected_model, "litellm_version": diagnostic_context["litellm_version"], "installation": diagnostic_context["installation"], "redacted_config_yaml": diagnostic_context["redacted_config_yaml"], + "next_question": next_question, + "next_question_index": next_question_index, + "diagnostic_answers_received": len( + diagnostic_context.get("diagnostic_answers", []) + ), + "next_request_body": next_request_body, + "next_curl": next_curl, "redacted_config": diagnostic_context["config"], "diagnostic_report": diagnostic_report, - "diagnostic_context": diagnostic_context, + "diagnostic_context": response_context, } +def _encode_diagnose_questions(questions: list[str]) -> str: + serialized = json.dumps(questions, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(serialized).decode("ascii") + + +def _decode_diagnose_questions(session_id: Optional[str]) -> Optional[list[str]]: + if not session_id: + return None + try: + decoded = base64.urlsafe_b64decode(session_id.encode("ascii")) + questions = json.loads(decoded.decode("utf-8")) + except Exception: + return None + if not isinstance(questions, list) or not all( + isinstance(question, str) for question in questions + ): + return None + return questions[:3] + + +def _build_diagnose_next_request( + *, + request_body: dict, + answers: list[str], + questions: list[str], + next_answer_placeholder: str, +) -> dict: + request_body = {**request_body} + request_body["diagnostic_session_id"] = _encode_diagnose_questions(questions) + request_body["diagnostic_answers"] = [*answers, next_answer_placeholder] + return request_body + + +def _build_diagnose_next_curl( + *, + next_request_body: dict, + next_answer_placeholder: str, +) -> str: + return ( + "curl -X POST http:///diagnose \\\n" + " -H 'Authorization: Bearer ' \\\n" + " -H 'Content-Type: application/json' \\\n" + f" -d '{json.dumps(next_request_body)}'" + ) + + +def _diagnose_answer_count(diagnose_request: DiagnoseRequest) -> int: + return len(diagnose_request.diagnostic_answers or []) + + +def _build_diagnose_question_report( + *, next_question_index: int, next_question: str, next_curl: str +) -> str: + return ( + "# LiteLLM Diagnostic Intake\n\n" + f"Question {next_question_index} of 3:\n\n" + f"{next_question}\n\n" + "Answer it by calling `/diagnose` again with this shape:\n\n" + f"```bash\n{next_curl}\n```" + ) + + def _get_diagnose_model_list(llm_router: Optional[Any]) -> list: if llm_router is None: return [] @@ -273,6 +335,10 @@ def _build_diagnose_prompt( f"Model selected for this diagnostic LLM call: {selected_model}\n" f"Issue description from admin: {request.issue_description or 'Not provided'}\n" f"Known reproduction steps from admin: {request.reproduction_steps or 'Not provided'}\n\n" + "Diagnostic questions asked:\n" + f"{json.dumps(diagnostic_context['diagnostic_questions'], indent=2)}\n\n" + "Admin answers:\n" + f"{json.dumps(request.diagnostic_answers or [], indent=2)}\n\n" "Use only this redacted diagnostic context; never invent secrets:\n" f"```json\n{serialized_context}\n```\n\n" "Return Markdown with these headings: Summary, Environment, Config and Models, " @@ -280,6 +346,86 @@ def _build_diagnose_prompt( ) +def _build_diagnose_questions_prompt( + *, + request: DiagnoseRequest, + selected_model: str, + diagnostic_context: dict, +) -> str: + serialized_context = json.dumps( + diagnostic_context, indent=2, sort_keys=True, default=str + ) + return ( + "You are debugging a LiteLLM proxy issue. Generate exactly three concise " + "questions to ask the admin before writing a reproduction report. The first " + "question must ask what issue they are seeing. The next two questions should " + "be follow-ups based on the issue, configured models, LiteLLM version, " + "installation/runtime, and redacted config. Do not ask for secrets or API keys.\n\n" + f"Model selected for this diagnostic LLM call: {selected_model}\n" + f"Known issue description: {request.issue_description or 'Not provided'}\n" + f"Known reproduction steps: {request.reproduction_steps or 'Not provided'}\n\n" + "Redacted diagnostic context:\n" + f"```json\n{serialized_context}\n```\n\n" + "Return only a numbered list with exactly three questions." + ) + + +def _parse_diagnose_questions(raw: str) -> list[str]: + questions: list[str] = [] + for line in raw.splitlines(): + cleaned = line.strip().lstrip("-*").strip() + if "." in cleaned[:4]: + _, _, cleaned = cleaned.partition(".") + cleaned = cleaned.strip() + if cleaned: + questions.append(cleaned) + if len(questions) == 3: + break + return questions + + +def _fallback_diagnose_questions(request: DiagnoseRequest) -> list[str]: + return [ + "What issue are you seeing in LiteLLM, and what did you expect to happen instead?", + ( + "Which model, provider, route, and request payload reproduces the issue?" + if not request.issue_description + else "Which model, provider, route, and request payload reproduces this issue?" + ), + "What errors, logs, status codes, or traces do you see when it fails?", + ] + + +async def _generate_diagnose_questions( + *, + llm_router: Any, + selected_model: str, + request: DiagnoseRequest, + diagnostic_context: dict, +) -> list[str]: + response = await run_with_timeout( + llm_router.acompletion( + model=selected_model, + messages=[ + { + "role": "user", + "content": _build_diagnose_questions_prompt( + request=request, + selected_model=selected_model, + diagnostic_context=diagnostic_context, + ), + } + ], + temperature=0.0, + ), + _DIAGNOSE_LLM_TIMEOUT_SECONDS, + ) + questions = _parse_diagnose_questions(_extract_diagnose_response_text(response)) + if len(questions) < 3: + return _fallback_diagnose_questions(request=request) + return questions + + def _extract_diagnose_response_text(response: Any) -> str: try: content = response.choices[0].message.content @@ -288,6 +434,19 @@ def _extract_diagnose_response_text(response: Any) -> str: return content or "" +def _build_diagnose_llm_error_report( + *, diagnostic_context: dict, error: Exception +) -> str: + return ( + "# LiteLLM Diagnostic Report\n\n" + "LiteLLM collected the redacted diagnostic context, but the selected " + "LLM failed while generating the final report. Use the context below " + "to reproduce the issue manually.\n\n" + f"LLM error: {str(error)}\n\n" + f"```json\n{json.dumps(diagnostic_context, indent=2, sort_keys=True, default=str)}\n```" + ) + + @router.get( "/test", tags=["health"], @@ -1757,6 +1916,8 @@ async def diagnose_endpoint( redacted_config = _redact_diagnose_payload(proxy_config.get_config_state()) redacted_models = _redact_diagnose_payload(configured_models) redacted_config_yaml = _dump_diagnose_config_yaml(redacted_config) + request_dict = diagnose_request.model_dump(exclude_none=True) + answers = list(diagnose_request.diagnostic_answers or []) diagnostic_context = { "litellm_version": _get_litellm_package_version(), "installation": _get_litellm_installation_info(), @@ -1764,6 +1925,8 @@ async def diagnose_endpoint( "configured_models": redacted_models, "config": redacted_config, "redacted_config_yaml": redacted_config_yaml, + "diagnostic_questions": [], + "diagnostic_answers": answers, "admin_user": { "user_id": user_api_key_dict.user_id, "user_role": ( @@ -1775,18 +1938,91 @@ async def diagnose_endpoint( } if selected_model is None or llm_router is None: + diagnostic_context["diagnostic_questions"] = _fallback_diagnose_questions( + request=diagnose_request + ) + next_question = diagnostic_context["diagnostic_questions"][0] + next_answer_placeholder = "" + next_request_body = _build_diagnose_next_request( + request_body=request_dict, + answers=answers, + questions=diagnostic_context["diagnostic_questions"], + next_answer_placeholder=next_answer_placeholder, + ) + next_curl = _build_diagnose_next_curl( + next_request_body=next_request_body, + next_answer_placeholder=next_answer_placeholder, + ) return _build_diagnose_response( used_llm=False, selected_model=selected_model, diagnostic_report=( - "# LiteLLM Diagnostic Report\n\n" - "No proxy model is configured for the diagnostic LLM call. " - "Call `/diagnose` again with a configured `model`, or add a " - "model to the proxy config, so LiteLLM can generate the full " - "Markdown reproduction report.\n\n" - f"```json\n{json.dumps(diagnostic_context, indent=2, sort_keys=True, default=str)}\n```" + "# LiteLLM Diagnostic Intake\n\n" + "No proxy model is configured for the diagnostic LLM call, so " + "LiteLLM is using built-in fallback questions.\n\n" + + _build_diagnose_question_report( + next_question_index=1, + next_question=next_question, + next_curl=next_curl, + ) ), diagnostic_context=diagnostic_context, + next_question=next_question, + next_question_index=1, + next_request_body=next_request_body, + next_curl=next_curl, + ) + + diagnostic_questions = _decode_diagnose_questions( + diagnose_request.diagnostic_session_id + ) + if diagnostic_questions is None: + try: + diagnostic_questions = await _generate_diagnose_questions( + llm_router=llm_router, + selected_model=selected_model, + request=diagnose_request, + diagnostic_context=diagnostic_context, + ) + except Exception as e: + verbose_proxy_logger.warning( + "Failed to generate /diagnose questions with LLM: %s", e + ) + diagnostic_questions = _fallback_diagnose_questions( + request=diagnose_request + ) + diagnostic_context["diagnostic_questions"] = diagnostic_questions + + diagnostic_answers_count = len(answers) + if diagnostic_answers_count < 3: + next_question_index = diagnostic_answers_count + 1 + next_question = diagnostic_questions[diagnostic_answers_count] + next_answer_placeholder = f"" + next_request_body = _build_diagnose_next_request( + request_body=request_dict, + answers=answers, + questions=diagnostic_questions, + next_answer_placeholder=next_answer_placeholder, + ) + return _build_diagnose_response( + used_llm=True, + selected_model=selected_model, + diagnostic_report=_build_diagnose_question_report( + next_question_index=next_question_index, + next_question=next_question, + next_curl=_build_diagnose_next_curl( + next_request_body=next_request_body, + next_answer_placeholder=next_answer_placeholder, + ), + ), + diagnostic_context=diagnostic_context, + next_question=next_question, + next_question_index=next_question_index, + next_request_body=next_request_body, + next_curl=_build_diagnose_next_curl( + next_request_body=next_request_body, + next_answer_placeholder=next_answer_placeholder, + ), ) prompt = _build_diagnose_prompt( @@ -1794,11 +2030,31 @@ async def diagnose_endpoint( selected_model=selected_model, diagnostic_context=diagnostic_context, ) - response = await llm_router.acompletion( - model=selected_model, - messages=[{"role": "user", "content": prompt}], - temperature=0.0, - ) + try: + response = await run_with_timeout( + llm_router.acompletion( + model=selected_model, + messages=[{"role": "user", "content": prompt}], + temperature=0.0, + ), + _DIAGNOSE_LLM_TIMEOUT_SECONDS, + ) + except Exception as e: + verbose_proxy_logger.warning( + "Failed to generate /diagnose report with LLM: %s", e + ) + return _build_diagnose_response( + used_llm=False, + selected_model=selected_model, + diagnostic_report=( + "# LiteLLM Diagnostic Report\n\n" + "The diagnostic LLM call failed while generating the final report, " + "so LiteLLM is returning the redacted diagnostic context and answers " + "for support to review.\n\n" + f"```json\n{json.dumps(diagnostic_context, indent=2, sort_keys=True, default=str)}\n```" + ), + diagnostic_context=diagnostic_context, + ) return _build_diagnose_response( used_llm=True, selected_model=selected_model, diff --git a/litellm/proxy/health_endpoints/diagnose_types.py b/litellm/proxy/health_endpoints/diagnose_types.py new file mode 100644 index 00000000000..d1561641308 --- /dev/null +++ b/litellm/proxy/health_endpoints/diagnose_types.py @@ -0,0 +1,38 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class DiagnoseRequest(BaseModel): + model: Optional[str] = Field( + default=None, + description=( + "Optional proxy model name to use for generating the diagnostic report. " + "When omitted, LiteLLM uses the first configured proxy deployment." + ), + ) + issue_description: Optional[str] = Field( + default=None, + description="Short description of the issue the admin is trying to reproduce.", + ) + reproduction_steps: Optional[str] = Field( + default=None, + description="Known reproduction steps or the behavior the admin has already tried.", + ) + diagnostic_answers: Optional[list[str]] = Field( + default=None, + description=( + "Answers collected so far. /diagnose returns one next_question at a time " + "plus next_request_body/next_curl showing how to send the next answer. " + "When at least three answers are provided, LiteLLM generates the final " + "support report." + ), + ) + diagnostic_session_id: Optional[str] = Field( + default=None, + description=( + "Opaque question-state token returned by /diagnose. Pass it back from " + "next_request_body/next_curl so LiteLLM can ask one question per request " + "without exposing future questions early." + ), + ) diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 8940cefda9f..a948997604c 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -659,7 +659,7 @@ def test_diagnose_requires_admin_view(): assert response.status_code == 403, response.text -def test_diagnose_generates_redacted_llm_report(monkeypatch): +def test_diagnose_asks_llm_generated_questions(monkeypatch): """ /diagnose should prompt a configured LLM with redacted proxy state and return the generated Markdown reproduction report. @@ -689,7 +689,11 @@ def test_diagnose_generates_redacted_llm_report(monkeypatch): choices=[ SimpleNamespace( message=SimpleNamespace( - content="# Reproduction report\n\nUse this report." + content=( + "1. What issue are you seeing?\n" + "2. Which Vertex model and route fail?\n" + "3. What status code or error appears?" + ) ) ) ] @@ -739,7 +743,19 @@ def test_diagnose_generates_redacted_llm_report(monkeypatch): "[REDACTED]" ) assert "master_key: '[REDACTED]'" in response_data["redacted_config_yaml"] - assert response_data["diagnostic_report"].startswith("# Reproduction report") + assert "diagnostic_questions" not in response_data + assert response_data["next_question"] == "What issue are you seeing?" + assert response_data["next_question_index"] == 1 + assert response_data["next_request_body"]["diagnostic_answers"] == [ + "" + ] + assert "diagnostic_questions" not in response_data["next_request_body"] + assert response_data["next_request_body"]["diagnostic_session_id"] + assert ( + "curl -X POST http:///diagnose" + in response_data["next_curl"] + ) + assert response_data["diagnostic_report"].startswith("# LiteLLM Diagnostic Intake") assert captured_call["model"] == "gpt-4o" prompt_text = captured_call["messages"][0]["content"] @@ -749,6 +765,287 @@ def test_diagnose_generates_redacted_llm_report(monkeypatch): assert "REDACTED" in prompt_text +def test_diagnose_generates_report_from_answers(monkeypatch): + """ + Once the admin provides answers to the three LLM-generated questions, + /diagnose should ask the LLM for the final support report. + """ + captured_calls = [] + + class FakeRouter: + def get_model_list(self, model_name=None): + deployments = [ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "deployment-1"}, + } + ] + if model_name is None: + return deployments + return [d for d in deployments if d["model_name"] == model_name] + + async def acompletion(self, **kwargs): + captured_calls.append(kwargs) + content = ( + "1. What issue are you seeing?\n" + "2. Which Vertex model and route fail?\n" + "3. What status code or error appears?" + if len(captured_calls) == 1 + else "# Reproduction report\n\nCloud Core fails with Vertex." + ) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=content))] + ) + + app = FastAPI() + app.include_router(_health_endpoints_module.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + client = TestClient(app) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", FakeRouter()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config", + SimpleNamespace( + get_config_state=lambda: { + "model_list": [{"model_name": "gpt-4o"}], + } + ), + ) + + response = client.post( + "/diagnose", + json={ + "issue_description": "Cloud Core doesn't work with Vertex.", + "diagnostic_answers": [ + "Cloud Core requests through LiteLLM fail for Vertex.", + "The failing route is /chat/completions with vertex_ai/gemini-1.5-pro.", + "The proxy returns a 400 saying project/location are missing.", + ], + }, + ) + + assert response.status_code == 200, response.text + response_data = response.json() + assert response_data["diagnostic_report"].startswith("# Reproduction report") + assert "Cloud Core fails with Vertex" in response_data["diagnostic_report"] + assert response_data["next_question"] is None + assert response_data["next_request_body"] is None + assert response_data["next_curl"] is None + assert len(captured_calls) == 2 + report_prompt = captured_calls[1]["messages"][0]["content"] + assert "Cloud Core doesn't work with Vertex." in report_prompt + assert "project/location are missing" in report_prompt + + +def test_diagnose_walks_questions_one_at_a_time(monkeypatch): + """ + /diagnose should guide admins through one question per request and show the + next request body they can copy back into curl. + """ + + class FakeRouter: + def get_model_list(self, model_name=None): + deployments = [ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "deployment-1"}, + } + ] + if model_name is None: + return deployments + return [d for d in deployments if d["model_name"] == model_name] + + async def acompletion(self, **kwargs): + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=( + "1. What issue are you seeing?\n" + "2. Which Vertex model and route fail?\n" + "3. What status code or error appears?" + ) + ) + ) + ] + ) + + app = FastAPI() + app.include_router(_health_endpoints_module.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + client = TestClient(app) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", FakeRouter()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config", + SimpleNamespace( + get_config_state=lambda: {"model_list": [{"model_name": "gpt-4o"}]} + ), + ) + + response = client.post( + "/diagnose", + json={ + "issue_description": "Cloud Core doesn't work with Vertex.", + "diagnostic_answers": ["Cloud Core fails on Vertex requests."], + }, + ) + + assert response.status_code == 200, response.text + response_data = response.json() + assert response_data["next_question"] == "Which Vertex model and route fail?" + assert response_data["next_question_index"] == 2 + assert response_data["diagnostic_answers_received"] == 1 + assert response_data["next_request_body"]["diagnostic_answers"] == [ + "Cloud Core fails on Vertex requests.", + "", + ] + assert "Cloud Core fails on Vertex requests." in response_data["next_curl"] + + +def test_diagnose_report_falls_back_when_llm_errors(monkeypatch): + """ + Provider failures during report generation should return a clean fallback + instead of propagating a raw 500 during an incident. + """ + captured_calls = [] + + class FakeRouter: + def get_model_list(self, model_name=None): + deployments = [ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "deployment-1"}, + } + ] + if model_name is None: + return deployments + return [d for d in deployments if d["model_name"] == model_name] + + async def acompletion(self, **kwargs): + captured_calls.append(kwargs) + if len(captured_calls) == 1: + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=( + "1. What issue are you seeing?\n" + "2. Which Vertex model and route fail?\n" + "3. What status code or error appears?" + ) + ) + ) + ] + ) + raise RuntimeError("provider unavailable") + + app = FastAPI() + app.include_router(_health_endpoints_module.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + client = TestClient(app) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", FakeRouter()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config", + SimpleNamespace( + get_config_state=lambda: {"model_list": [{"model_name": "gpt-4o"}]} + ), + ) + + response = client.post( + "/diagnose", + json={ + "issue_description": "Cloud Core doesn't work with Vertex.", + "diagnostic_answers": [ + "Cloud Core requests through LiteLLM fail for Vertex.", + "The failing route is /chat/completions.", + "The proxy returns a 400.", + ], + }, + ) + + assert response.status_code == 200, response.text + response_data = response.json() + assert response_data["used_llm"] is False + assert response_data["diagnostic_report"].startswith("# LiteLLM Diagnostic Report") + assert "provider unavailable" not in response_data["diagnostic_report"] + assert len(captured_calls) == 2 + + +def test_diagnose_advances_to_next_question(monkeypatch): + """ + Each intake call should ask only the next unanswered question and return a + concrete request body the admin can paste back into curl. + """ + + class FakeRouter: + def get_model_list(self, model_name=None): + deployments = [ + {"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}} + ] + if model_name is None: + return deployments + return [d for d in deployments if d["model_name"] == model_name] + + async def acompletion(self, **kwargs): + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=( + "1. What issue are you seeing?\n" + "2. Which Vertex model and route fail?\n" + "3. What status code or error appears?" + ) + ) + ) + ] + ) + + app = FastAPI() + app.include_router(_health_endpoints_module.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + client = TestClient(app) + + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", FakeRouter()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_config", + SimpleNamespace( + get_config_state=lambda: {"model_list": [{"model_name": "gpt-4o"}]} + ), + ) + + response = client.post( + "/diagnose", + json={ + "issue_description": "Cloud Core doesn't work with Vertex.", + "diagnostic_answers": ["Cloud Core fails when routed to Vertex."], + }, + ) + + assert response.status_code == 200, response.text + response_data = response.json() + assert response_data["next_question"] == "Which Vertex model and route fail?" + assert response_data["next_question_index"] == 2 + assert response_data["next_request_body"]["diagnostic_answers"] == [ + "Cloud Core fails when routed to Vertex.", + "", + ] + assert response_data["diagnostic_answers_received"] == 1 + + def test_diagnose_prompts_for_model_when_no_llm_is_configured(monkeypatch): """ /diagnose should return redacted context plus next-step instructions when @@ -780,7 +1077,11 @@ def test_diagnose_prompts_for_model_when_no_llm_is_configured(monkeypatch): assert response_data["litellm_version"] assert response_data["installation"]["install_source"] assert "master_key: '[REDACTED]'" in response_data["redacted_config_yaml"] - assert "configured `model`" in response_data["diagnostic_report"] + assert response_data["next_question"] + assert response_data["next_request_body"]["diagnostic_answers"] == [ + "" + ] + assert "No proxy model is configured" in response_data["diagnostic_report"] assert "sk-master-secret" not in response_data["diagnostic_report"]