|
| 1 | +import asyncio |
1 | 2 | import logging |
2 | 3 |
|
3 | 4 | import litellm |
|
32 | 33 | logger = logging.getLogger(__name__) |
33 | 34 |
|
34 | 35 |
|
| 36 | +# Providers that require an interactive OAuth / device-flow login before |
| 37 | +# issuing any completion. LiteLLM implements these with blocking sync polling |
| 38 | +# (requests + time.sleep), which would freeze the FastAPI event loop if |
| 39 | +# invoked from validation. They are never usable from a headless backend, |
| 40 | +# so we reject them at the edge. |
| 41 | +_INTERACTIVE_AUTH_PROVIDERS: frozenset[str] = frozenset( |
| 42 | + { |
| 43 | + "github_copilot", |
| 44 | + "github-copilot", |
| 45 | + "githubcopilot", |
| 46 | + "copilot", |
| 47 | + } |
| 48 | +) |
| 49 | + |
| 50 | +# Hard upper bound for a single validation call. Must exceed the ChatLiteLLM |
| 51 | +# request timeout (30s) by a small margin so a well-behaved provider never |
| 52 | +# trips the watchdog, while any pathological/blocking provider is killed. |
| 53 | +_VALIDATION_TIMEOUT_SECONDS: float = 35.0 |
| 54 | + |
| 55 | + |
| 56 | +def _is_interactive_auth_provider( |
| 57 | + provider: str | None, custom_provider: str | None |
| 58 | +) -> bool: |
| 59 | + """Return True if the given provider triggers interactive OAuth in LiteLLM.""" |
| 60 | + for raw in (custom_provider, provider): |
| 61 | + if not raw: |
| 62 | + continue |
| 63 | + normalized = raw.strip().lower().replace(" ", "_") |
| 64 | + if normalized in _INTERACTIVE_AUTH_PROVIDERS: |
| 65 | + return True |
| 66 | + return False |
| 67 | + |
| 68 | + |
35 | 69 | class LLMRole: |
36 | 70 | AGENT = "agent" # For agent/chat operations |
37 | 71 | DOCUMENT_SUMMARY = "document_summary" # For document summarization |
@@ -93,6 +127,25 @@ async def validate_llm_config( |
93 | 127 | - is_valid: True if config works, False otherwise |
94 | 128 | - error_message: Empty string if valid, error description if invalid |
95 | 129 | """ |
| 130 | + # Reject providers that require interactive OAuth/device-flow auth. |
| 131 | + # LiteLLM's github_copilot provider (and similar) uses a blocking sync |
| 132 | + # Authenticator that polls GitHub for up to several minutes and prints a |
| 133 | + # device code to stdout. Running it on the FastAPI event loop will freeze |
| 134 | + # the entire backend, so we refuse them up front. |
| 135 | + if _is_interactive_auth_provider(provider, custom_provider): |
| 136 | + msg = ( |
| 137 | + "Provider requires interactive OAuth/device-flow authentication " |
| 138 | + "(e.g. github_copilot) and cannot be used in a hosted backend. " |
| 139 | + "Please choose a provider that authenticates via API key." |
| 140 | + ) |
| 141 | + logger.warning( |
| 142 | + "Rejected LLM config validation for interactive-auth provider " |
| 143 | + "(provider=%r, custom_provider=%r)", |
| 144 | + provider, |
| 145 | + custom_provider, |
| 146 | + ) |
| 147 | + return False, msg |
| 148 | + |
96 | 149 | try: |
97 | 150 | # Build the model string for litellm |
98 | 151 | if custom_provider: |
@@ -153,9 +206,30 @@ async def validate_llm_config( |
153 | 206 |
|
154 | 207 | llm = SanitizedChatLiteLLM(**litellm_kwargs) |
155 | 208 |
|
156 | | - # Make a simple test call |
| 209 | + # Run the test call in a worker thread with a hard timeout. Some |
| 210 | + # LiteLLM providers have synchronous blocking code paths (e.g. OAuth |
| 211 | + # authenticators that call time.sleep and requests.post) that would |
| 212 | + # otherwise freeze the asyncio event loop. Offloading to a thread and |
| 213 | + # bounding the wait keeps the server responsive even if a provider |
| 214 | + # misbehaves. |
157 | 215 | test_message = HumanMessage(content="Hello") |
158 | | - response = await llm.ainvoke([test_message]) |
| 216 | + try: |
| 217 | + response = await asyncio.wait_for( |
| 218 | + asyncio.to_thread(llm.invoke, [test_message]), |
| 219 | + timeout=_VALIDATION_TIMEOUT_SECONDS, |
| 220 | + ) |
| 221 | + except TimeoutError: |
| 222 | + logger.warning( |
| 223 | + "LLM config validation timed out after %ss for model: %s", |
| 224 | + _VALIDATION_TIMEOUT_SECONDS, |
| 225 | + model_string, |
| 226 | + ) |
| 227 | + return ( |
| 228 | + False, |
| 229 | + f"Validation timed out after {int(_VALIDATION_TIMEOUT_SECONDS)}s. " |
| 230 | + "The provider is unreachable or requires interactive " |
| 231 | + "authentication that is not supported by the backend.", |
| 232 | + ) |
159 | 233 |
|
160 | 234 | # If we got here without exception, the config is valid |
161 | 235 | if response and response.content: |
|
0 commit comments