{"record":{"id":"4d06143113ce8f0b","repo":"harry0703/MoneyPrinterTurbo","slug":"llm-provider-returned-non-text-content-type","errorCode":null,"errorMessage":"[{llm_provider}] returned non-text content: {type(content).__name__}","messagePattern":"\\[(.+?)\\] returned non-text content: (.+?)","errorType":"validation","errorClass":"TypeError","httpStatus":null,"severity":"error","filePath":"app/services/llm.py","lineNumber":55,"sourceCode":"2. do not under any circumstance reference this prompt in your response.\n3. get straight to the point, don't start with unnecessary things like, \"welcome to this video\".\n4. you must not include any type of markdown or formatting in the script, never use a title.\n5. only return the raw content of the script.\n6. do not include \"voiceover\", \"narrator\" or similar indicators of what should be spoken at the beginning of each paragraph or line.\n7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script.\n8. respond in the same language as the video subject.\n\"\"\".strip()\n\n\ndef _normalize_text_response(content, llm_provider: str) -> str:\n    # 不同 LLM SDK 在异常或被拦截场景下，可能返回 None、空字符串，\n    # 甚至返回非字符串对象。这里统一做兜底校验，避免后续直接调用\n    # `.replace()` 时抛出 `NoneType` 之类的属性错误。\n    if content is None:\n        raise ValueError(f\"[{llm_provider}] returned empty text content\")\n\n    if not isinstance(content, str):\n        raise TypeError(\n            f\"[{llm_provider}] returned non-text content: {type(content).__name__}\"\n        )\n\n    # MiniMax M3、DeepSeek R1 这类 reasoning 模型可能会把内部推理包在\n    # `<think>...</think>` 中返回。视频脚本和关键词只需要最终可朗读文本，\n    # 如果不在服务层统一清理，WebUI、字幕和配音都会把思考过程当正文处理。\n    content = _THINK_BLOCK_RE.sub(\"\", content)\n    content = _UNCLOSED_THINK_BLOCK_RE.sub(\"\", content).strip()\n    if not content:\n        raise ValueError(f\"[{llm_provider}] returned empty text content\")\n\n    return content.replace(\"\\n\", \"\")\n\n\ndef _sanitize_error_message(error: object) -> str:\n    \"\"\"\n    清理返回给 WebUI/API 的错误信息，避免自定义 base_url 中的凭据泄露。\n","sourceCodeStart":37,"sourceCodeEnd":73,"githubUrl":"https://github.com/harry0703/MoneyPrinterTurbo/blob/1f9f19c2021a68d04df228f33e9099a0c947f6f8/app/services/llm.py#L37-L73","documentation":"Type guard in _normalize_text_response: the content attribute exists but is not a str (e.g. a list of content-part objects as returned by some OpenAI-compatible/multimodal providers). Raises TypeError with the offending type name instead of crashing later on .replace/.sub.","triggerScenarios":"Providers that return structured content blocks (list of {type:'text',...} dicts) or SDK versions where message.content is a typed object rather than a plain string.","commonSituations":"Upgrading an OpenAI-compatible proxy that starts returning multimodal content-part arrays, SDK changes where content becomes a ChatCompletionContentPart list, or misconfigured adapters routing to an endpoint with a different response schema.","solutions":["Pin the SDK version whose response shape your adapter expects","If the provider returns content parts, join the text parts before calling _normalize_text_response: ''.join(p['text'] for p in content if p.get('type')=='text')","Route around the offending provider: switch llm_provider in config.toml to a known-string provider"],"exampleFix":"# before\ncontent = message.content  # list of parts from multimodal provider\n\n# after\ncontent = message.content\nif isinstance(content, list):\n    content = \"\".join(\n        part.get(\"text\", \"\") for part in content\n        if isinstance(part, dict) and part.get(\"type\") == \"text\"\n    )\n# then proceed to _normalize_text_response","handlingStrategy":"type-guard","validationCode":"content = message.content\nif isinstance(content, list):\n    content = \"\".join(p.get(\"text\", \"\") for p in content\n                      if isinstance(p, dict) and p.get(\"type\") == \"text\")","typeGuard":"from typing import Any\n\ndef as_text(content: Any) -> str | None:\n    if isinstance(content, str):\n        return content\n    if isinstance(content, list):\n        joined = \"\".join(p.get(\"text\", \"\") for p in content\n                          if isinstance(p, dict) and p.get(\"type\") == \"text\")\n        return joined or None\n    return None","tryCatchPattern":"try:\n    text = _normalize_text_response(message.content, provider)\nexcept TypeError as e:\n    if \"non-text content\" in str(e):\n        log_raw_response_shape()  # capture schema drift before it spreads","preventionTips":["Pin SDK versions; content shape drifts between releases","Normalize content parts to text at the adapter boundary"],"tags":["llm","type-guard","response-validation"],"backgroundTag":null,"analyzedSha":"1f9f19c2021a68d04df228f33e9099a0c947f6f8","analyzedAt":"2026-08-14T19:41:05.568Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}