{"record":{"id":"50ab5ec459a58722","repo":"harry0703/MoneyPrinterTurbo","slug":"llm-provider-returned-empty-message","errorCode":null,"errorMessage":"[{llm_provider}] returned empty message","messagePattern":"\\[(.+?)\\] returned empty message","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"app/services/llm.py","lineNumber":97,"sourceCode":"    message = str(error)\n    message = _URL_USERINFO_RE.sub(r\"\\1***:***@\", message)\n    message = _SENSITIVE_QUERY_RE.sub(r\"\\1***\", message)\n    return message\n\n\ndef _extract_chat_completion_text(response, llm_provider: str) -> str:\n    # OpenAI 兼容接口在异常场景下，可能返回没有 choices、\n    # 或者 choices/message/content 为空的响应对象。\n    # 这里统一做结构校验，避免出现 `NoneType is not subscriptable`\n    # 这类底层属性访问错误。\n    choices = getattr(response, \"choices\", None)\n    if not choices:\n        raise ValueError(f\"[{llm_provider}] returned empty choices\")\n\n    first_choice = choices[0]\n    message = getattr(first_choice, \"message\", None)\n    if message is None:\n        raise ValueError(f\"[{llm_provider}] returned empty message\")\n\n    content = getattr(message, \"content\", None)\n    return _normalize_text_response(content, llm_provider)\n\n\ndef _get_response_field(value, key: str):\n    \"\"\"兼容 dict 和 SDK 响应对象的字段读取。\"\"\"\n    if isinstance(value, dict):\n        return value.get(key)\n\n    try:\n        return value[key]\n    except (KeyError, TypeError, AttributeError):\n        return getattr(value, key, None)\n\n\ndef _extract_qwen_generation_text(response) -> str:\n    \"\"\"","sourceCodeStart":79,"sourceCodeEnd":115,"githubUrl":"https://github.com/harry0703/MoneyPrinterTurbo/blob/1f9f19c2021a68d04df228f33e9099a0c947f6f8/app/services/llm.py#L79-L115","documentation":"Structural guard in _extract_chat_completion_text: choices[0] exists but its .message attribute is None. Some OpenAI-compatible providers omit the message object entirely on error or partial responses; accessing .content on None would crash.","triggerScenarios":"A completion choice with message=null — seen with providers that return choices containing only a delta/error stub, or truncated responses where the choice was constructed without a message.","commonSituations":"Streaming-shaped responses accidentally consumed via the non-streaming API, gateway transformations dropping fields, or provider-specific schema drift after an update.","solutions":["Dump the full response object to inspect the actual choice shape","Update or pin the provider adapter/SDK to a version matching the current API schema","Retry once; partial-choice responses are often transient provider glitches","Report/fix the gateway if it mangles the message field"],"exampleFix":"# before\nmessage = response.choices[0].message\ncontent = message.content  # message is None -> AttributeError\n\n# after\nchoice = response.choices[0]\nmessage = getattr(choice, \"message\", None)\nif message is None:\n    raise ValueError(f\"[{provider}] returned empty message: {choice!r}\")\ncontent = message.content","handlingStrategy":"type-guard","validationCode":"choice = response.choices[0]\nassert getattr(choice, \"message\", None) is not None, f\"choice lacks message: {choice!r}\"","typeGuard":"def has_message(choice) -> bool:\n    return getattr(choice, \"message\", None) is not None","tryCatchPattern":null,"preventionTips":["Never index into choices without checking message first","Watch for streaming-shaped responses consumed via non-streaming APIs"],"tags":["llm","response-validation","openai-compatible"],"backgroundTag":null,"analyzedSha":"1f9f19c2021a68d04df228f33e9099a0c947f6f8","analyzedAt":"2026-08-14T19:41:05.568Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}