hotfix-dsv4-vision-exp.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env python3
  2. """Install native DeepSeek-V4-Flash-Vision-Exp image support into Anemll vLLM.
  3. The 0.1.1 image's ``DeepseekV4ForCausalLM`` is text-only. Vision-Exp ships a
  4. 32-layer ViT + Aligner and ``<|deepseek_image|>`` prompt tokens. This
  5. startup patch:
  6. 1. Appends a fail-closed import hook to ``nvidia/model.py`` that constructs
  7. the tower, maps ``vision.*`` / ``aligner.*`` / ``image_*`` / ``bias_vl``
  8. weights, and registers a vLLM multimodal processor.
  9. 2. Remaps DSpark draft ``ffn.gate.bias_vl`` → ``e_score_correction_bias_vl``
  10. (Anemll only rewrote names ending in ``.ffn.gate.bias``).
  11. 3. Relaxes the Vision-Exp encoder's rejection of already-substituted
  12. ``<|deepseek_image|>`` text so OpenAI ``image_url`` parts survive
  13. vLLM's chat parser, then restores the official Chat Completions rule:
  14. images in ``user`` messages only (``system`` / ``assistant`` → 400).
  15. Video is not wired: the official weights, ``encoding/``, and ``inference/``
  16. have no video encoder. GIF is decoded as a still RGB frame.
  17. Usage (inside the container, after the encoder copy):
  18. python3 hotfix-dsv4-vision-exp.py
  19. """
  20. from __future__ import annotations
  21. import sys
  22. from pathlib import Path
  23. DEFAULT_MODEL = Path(
  24. "/usr/local/lib/python3.12/dist-packages/vllm/models/deepseek_v4/nvidia/model.py"
  25. )
  26. DEFAULT_ENCODING = Path(
  27. "/usr/local/lib/python3.12/dist-packages/vllm/tokenizers/deepseek_v4_encoding.py"
  28. )
  29. DEFAULT_DSPARK = Path(
  30. "/usr/local/lib/python3.12/dist-packages/vllm/models/deepseek_v4/nvidia/dspark.py"
  31. )
  32. DEFAULT_PATCHES = Path("/opt/dspark-patches/vision_exp")
  33. MODEL_MARK = "# [vision-exp-hotfix] native DeepSeek-V4-Flash-Vision-Exp image tower"
  34. ENC_MARK = "# [vision-exp-hotfix] allow vLLM-inserted image placeholders"
  35. ENC_ROLE_MARK = "# [vision-exp-hotfix] images only in user messages"
  36. ENC_ROLE_PAIRED_MARK = "# [vision-exp-hotfix] paired <image> tag (issue 165)"
  37. DSPARK_MARK = "# [vision-exp-hotfix] remap ffn.gate.bias_vl"
  38. DSPARK_GATE_BIAS_OLD = ''' if name.endswith(".ffn.gate.bias"):
  39. name = name.replace(
  40. ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
  41. )
  42. param = params_dict[name]'''
  43. DSPARK_GATE_BIAS_NEW = f''' if name.endswith(".ffn.gate.bias_vl"):
  44. name = name.replace(
  45. ".ffn.gate.bias_vl",
  46. ".ffn.gate.e_score_correction_bias_vl",
  47. )
  48. elif name.endswith(".ffn.gate.bias"):
  49. name = name.replace(
  50. ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
  51. )
  52. if name not in params_dict:
  53. continue {DSPARK_MARK}
  54. param = params_dict[name]'''
  55. MODEL_INJECT = f'''
  56. {MODEL_MARK}
  57. import sys as _dspark_vision_sys
  58. if "/opt/dspark-patches" not in _dspark_vision_sys.path:
  59. _dspark_vision_sys.path.insert(0, "/opt/dspark-patches")
  60. from vision_exp.apply import apply_vision_exp as _dspark_apply_vision_exp
  61. _dspark_apply_vision_exp(
  62. DeepseekV4Model=DeepseekV4Model,
  63. DeepseekV4ForCausalLM=DeepseekV4ForCausalLM,
  64. DeepseekV4MoE=DeepseekV4MoE,
  65. )
  66. '''
  67. CONTENT_CHECK = (
  68. "if isinstance(content, str) and IMAGE_PLACEHOLDER in content:"
  69. )
  70. CONTENT_CHECK_NEW = (
  71. "if False and isinstance(content, str) and IMAGE_PLACEHOLDER in content:"
  72. f" {ENC_MARK}"
  73. )
  74. REASONING_CHECK = (
  75. "if isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:"
  76. )
  77. REASONING_CHECK_NEW = (
  78. "if False and isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:"
  79. f" {ENC_MARK}"
  80. )
  81. TEXT_CHECK = "if IMAGE_PLACEHOLDER in text:"
  82. TEXT_CHECK_NEW = f"if False and IMAGE_PLACEHOLDER in text: {ENC_MARK}"
  83. ENC_ROLE_INJECT = f'''
  84. {ENC_ROLE_MARK}
  85. {ENC_ROLE_PAIRED_MARK}
  86. def _dspark_vision_text_has_image(text: str) -> bool:
  87. if IMAGE_PLACEHOLDER in text:
  88. return True
  89. needle, close = "<image>", "</image>"
  90. start = 0
  91. while True:
  92. i = text.find(needle, start)
  93. if i < 0:
  94. return False
  95. if text.find(close, i + len(needle)) >= 0:
  96. return True
  97. start = i + len(needle)
  98. def _dspark_vision_value_has_image(value) -> bool:
  99. if isinstance(value, str):
  100. return _dspark_vision_text_has_image(value)
  101. if not isinstance(value, list):
  102. return False
  103. for block in value:
  104. if not isinstance(block, dict):
  105. continue
  106. if block.get("type") in ("image", "image_url"):
  107. return True
  108. text = block.get("text") or ""
  109. if isinstance(text, str) and _dspark_vision_text_has_image(text):
  110. return True
  111. nested = block.get("content")
  112. if isinstance(nested, list) and _dspark_vision_value_has_image(nested):
  113. return True
  114. return False
  115. def _validate_no_image_sp_tokens(msg):
  116. """Official restriction: images in user messages only (system/assistant → 400)."""
  117. reasoning_content = msg.get("reasoning_content")
  118. if isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:
  119. raise ValueError(
  120. "reasoning_content contains image special token "
  121. + repr(IMAGE_PLACEHOLDER)
  122. )
  123. role = msg.get("role")
  124. if role in ("user", "developer"):
  125. return
  126. if _dspark_vision_value_has_image(msg.get("content")) or _dspark_vision_value_has_image(
  127. msg.get("content_blocks")
  128. ):
  129. raise ValueError(
  130. "Images are supported in user messages only: "
  131. "images in " + repr(role) + " messages return a 400 error."
  132. )
  133. '''
  134. def patch_model_text(source: str) -> tuple[str, str]:
  135. if MODEL_MARK in source:
  136. return source, "skipped"
  137. if "class DeepseekV4ForCausalLM" not in source or "class DeepseekV4MoE" not in source:
  138. return source, "drift:missing-dsv4-class"
  139. updated = source.rstrip() + "\n" + MODEL_INJECT
  140. compile(updated, "model.py", "exec")
  141. return updated, "applied"
  142. def patch_encoding_text(source: str) -> tuple[str, str]:
  143. if (
  144. ENC_MARK in source
  145. and ENC_ROLE_MARK in source
  146. and ENC_ROLE_PAIRED_MARK in source
  147. and CONTENT_CHECK_NEW in source
  148. ):
  149. return source, "skipped"
  150. if "IMAGE_PLACEHOLDER" not in source:
  151. # Lineages whose deepseek_v4_encoding.py has no image-placeholder logic
  152. # have nothing to relax here. The Anemll image this hotfix was written
  153. # for rejects already-substituted placeholders at the encoder; a
  154. # lineage without them makes the encoding patch a no-op.
  155. return source, "skipped-no-placeholder"
  156. if ENC_MARK not in source:
  157. missing = []
  158. for old, new in (
  159. (CONTENT_CHECK, CONTENT_CHECK_NEW),
  160. (REASONING_CHECK, REASONING_CHECK_NEW),
  161. (TEXT_CHECK, TEXT_CHECK_NEW),
  162. ):
  163. if source.count(old) != 1:
  164. missing.append(f"{old!r}={source.count(old)}")
  165. continue
  166. source = source.replace(old, new, 1)
  167. if missing:
  168. return source, "drift:" + ",".join(missing)
  169. if ENC_ROLE_MARK in source and ENC_ROLE_PAIRED_MARK not in source:
  170. source = source[: source.rfind(ENC_ROLE_MARK)].rstrip() + "\n"
  171. if ENC_ROLE_MARK not in source:
  172. source = source.rstrip() + "\n" + ENC_ROLE_INJECT
  173. compile(source, "encoding.py", "exec")
  174. return source, "applied"
  175. def patch_dspark_text(source: str) -> tuple[str, str]:
  176. if DSPARK_MARK in source and DSPARK_GATE_BIAS_NEW in source:
  177. return source, "skipped"
  178. if source.count(DSPARK_GATE_BIAS_OLD) != 1:
  179. return source, (
  180. "drift:dspark-gate-bias-remap="
  181. f"{source.count(DSPARK_GATE_BIAS_OLD)}"
  182. )
  183. updated = source.replace(DSPARK_GATE_BIAS_OLD, DSPARK_GATE_BIAS_NEW, 1)
  184. compile(updated, "dspark.py", "exec")
  185. return updated, "applied"
  186. def _write(path: Path, original: str, updated: str, status: str) -> None:
  187. if status == "applied":
  188. path.write_text(updated)
  189. elif status not in ("skipped", "skipped-no-placeholder"):
  190. raise SystemExit(f"FATAL: {path} {status}")
  191. print(f"vision-exp hotfix {path.name:40s}: {status}")
  192. def main() -> int:
  193. if len(sys.argv) > 1 and sys.argv[1] == "--status":
  194. model = DEFAULT_MODEL.read_text() if DEFAULT_MODEL.is_file() else ""
  195. encoding = DEFAULT_ENCODING.read_text() if DEFAULT_ENCODING.is_file() else ""
  196. dspark = DEFAULT_DSPARK.read_text() if DEFAULT_DSPARK.is_file() else ""
  197. print(
  198. "vision-exp model.py :",
  199. "APPLIED" if MODEL_MARK in model else "NOT APPLIED",
  200. )
  201. print(
  202. "vision-exp encoding.py :",
  203. "APPLIED"
  204. if ENC_MARK in encoding and ENC_ROLE_MARK in encoding
  205. and ENC_ROLE_PAIRED_MARK in encoding
  206. else "NOT APPLIED",
  207. )
  208. print(
  209. "vision-exp dspark.py :",
  210. "APPLIED" if DSPARK_MARK in dspark else "NOT APPLIED",
  211. )
  212. ok = (
  213. MODEL_MARK in model
  214. and ENC_MARK in encoding
  215. and ENC_ROLE_MARK in encoding
  216. and ENC_ROLE_PAIRED_MARK in encoding
  217. and DSPARK_MARK in dspark
  218. )
  219. return 0 if ok else 1
  220. patches = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PATCHES
  221. model_path = Path(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_MODEL
  222. encoding_path = Path(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_ENCODING
  223. dspark_path = Path(sys.argv[4]) if len(sys.argv) > 4 else DEFAULT_DSPARK
  224. if not (patches / "apply.py").is_file() or not (patches / "vision.py").is_file():
  225. print(f"FATAL: Vision-Exp overlay missing under {patches}", file=sys.stderr)
  226. return 1
  227. if not model_path.is_file():
  228. print(f"FATAL: {model_path} missing", file=sys.stderr)
  229. return 1
  230. if not encoding_path.is_file():
  231. print(
  232. f"FATAL: {encoding_path} missing (encoder copy must run first)",
  233. file=sys.stderr,
  234. )
  235. return 1
  236. if not dspark_path.is_file():
  237. print(f"FATAL: {dspark_path} missing", file=sys.stderr)
  238. return 1
  239. model_src = model_path.read_text()
  240. model_new, model_status = patch_model_text(model_src)
  241. _write(model_path, model_src, model_new, model_status)
  242. enc_src = encoding_path.read_text()
  243. enc_new, enc_status = patch_encoding_text(enc_src)
  244. _write(encoding_path, enc_src, enc_new, enc_status)
  245. dspark_src = dspark_path.read_text()
  246. dspark_new, dspark_status = patch_dspark_text(dspark_src)
  247. _write(dspark_path, dspark_src, dspark_new, dspark_status)
  248. return 0
  249. if __name__ == "__main__":
  250. raise SystemExit(main())