test_chat_encoding.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. #!/usr/bin/env python3
  2. """Parity of this stack's V4.1 encoder against DeepSeek's encoding/encoding.py.
  3. Loads the checkpoint's reference encoder (the only prompt-format spec DeepSeek
  4. ships) and checks thinking-off, tools, and reasoning-effort renders. When SGLang
  5. is importable (in the serving image), also diffs encoding_dsv41.encode_messages
  6. after the publisher effort remap.
  7. python3 tests/test_chat_encoding.py
  8. python3 tests/test_chat_encoding.py --src ~/NewModels/DeepSeek-V4.1-Flash
  9. """
  10. import argparse
  11. import difflib
  12. import importlib.util
  13. import json
  14. import os
  15. import sys
  16. from pathlib import Path
  17. ROOT = Path(__file__).resolve().parents[1]
  18. sys.path.insert(0, str(ROOT / 'adapter'))
  19. from encoding_compat import PUBLISHER_REASONING_EFFORT, install_encoder # noqa: E402
  20. DEFAULT_SRC = os.environ.get(
  21. 'MODEL_PATH', os.path.expanduser('~/NewModels/DeepSeek-V4.1-Flash'))
  22. CASES = [
  23. ('plain user, thinking',
  24. dict(messages=[{'role': 'user', 'content': 'How many r in strawberry?'}],
  25. thinking_mode='thinking')),
  26. ('plain user, chat mode',
  27. dict(messages=[{'role': 'user', 'content': 'How many r in strawberry?'}],
  28. thinking_mode='chat')),
  29. ('system + user, chat mode',
  30. dict(messages=[{'role': 'system', 'content': 'You are terse.'},
  31. {'role': 'user', 'content': 'Hi'}],
  32. thinking_mode='chat')),
  33. ('reasoning effort low',
  34. dict(messages=[{'role': 'user', 'content': 'Hi'}],
  35. thinking_mode='thinking', reasoning_effort='low')),
  36. ('reasoning effort high',
  37. dict(messages=[{'role': 'user', 'content': 'Hi'}],
  38. thinking_mode='thinking', reasoning_effort='high')),
  39. ('reasoning effort max',
  40. dict(messages=[{'role': 'user', 'content': 'Hi'}],
  41. thinking_mode='thinking', reasoning_effort='max')),
  42. ('reasoning effort int',
  43. dict(messages=[{'role': 'user', 'content': 'Hi'}],
  44. thinking_mode='thinking', reasoning_effort=42)),
  45. ('multi turn, chat mode',
  46. dict(messages=[{'role': 'user', 'content': 'First question'},
  47. {'role': 'assistant', 'content': 'First answer'},
  48. {'role': 'user', 'content': 'Second question'}],
  49. thinking_mode='chat')),
  50. ('consecutive user messages merge',
  51. dict(messages=[{'role': 'user', 'content': 'part one'},
  52. {'role': 'user', 'content': 'part two'}],
  53. thinking_mode='thinking')),
  54. ('tools in system message, thinking',
  55. dict(messages=[{'role': 'system', 'content': 'You may use tools.'},
  56. {'role': 'user', 'content': 'Weather in Paris?'}],
  57. tools=[{'type': 'function', 'function': {
  58. 'name': 'get_weather', 'description': 'Get weather',
  59. 'parameters': {'type': 'object',
  60. 'properties': {'city': {'type': 'string'}},
  61. 'required': ['city']}}}],
  62. thinking_mode='thinking')),
  63. ('chat mode with tools',
  64. dict(messages=[{'role': 'system', 'content': 'sys'},
  65. {'role': 'user', 'content': 'Weather?'}],
  66. thinking_mode='chat',
  67. tools=[{'type': 'function',
  68. 'function': {'name': 'w', 'parameters': {'type': 'object'}}}])),
  69. ('assistant tool call + tool result',
  70. dict(messages=[{'role': 'system', 'content': 'You may use tools.'},
  71. {'role': 'user', 'content': 'Weather in Paris?'},
  72. {'role': 'assistant', 'content': '',
  73. 'reasoning_content': 'need the tool',
  74. 'tool_calls': [{'type': 'function', 'id': 'c1', 'function': {
  75. 'name': 'get_weather',
  76. 'arguments': {'city': 'Paris', 'days': 3, 'metric': True}}}]},
  77. {'role': 'tool', 'tool_call_id': 'c1', 'content': '18C, cloudy'},
  78. {'role': 'user', 'content': 'And tomorrow?'}],
  79. tools=[{'type': 'function', 'function': {
  80. 'name': 'get_weather', 'description': 'Get weather',
  81. 'parameters': {'type': 'object',
  82. 'properties': {'city': {'type': 'string'}}}}}],
  83. thinking_mode='thinking')),
  84. ('two tool results merge into one user turn',
  85. dict(messages=[{'role': 'user', 'content': 'Compare Paris and Rome'},
  86. {'role': 'assistant', 'content': '', 'tool_calls': [
  87. {'type': 'function', 'id': 'a',
  88. 'function': {'name': 'w', 'arguments': {'city': 'Paris'}}},
  89. {'type': 'function', 'id': 'b',
  90. 'function': {'name': 'w', 'arguments': {'city': 'Rome'}}}]},
  91. {'role': 'tool', 'tool_call_id': 'a', 'content': '18C'},
  92. {'role': 'tool', 'tool_call_id': 'b', 'content': '24C'}],
  93. thinking_mode='thinking')),
  94. ('out-of-order tool results',
  95. dict(messages=[{'role': 'user', 'content': 'both'},
  96. {'role': 'assistant', 'content': '', 'tool_calls': [
  97. {'type': 'function', 'id': 'a',
  98. 'function': {'name': 'w', 'arguments': {'c': 'P'}}},
  99. {'type': 'function', 'id': 'b',
  100. 'function': {'name': 'w', 'arguments': {'c': 'R'}}}]},
  101. {'role': 'tool', 'tool_call_id': 'b', 'content': 'second'},
  102. {'role': 'tool', 'tool_call_id': 'a', 'content': 'first'}],
  103. thinking_mode='thinking')),
  104. ('response_format schema',
  105. dict(messages=[{'role': 'system', 'content': 'sys'},
  106. {'role': 'user', 'content': 'Hi'}],
  107. response_format={'type': 'json_object',
  108. 'schema': {'type': 'object',
  109. 'properties': {'a': {'type': 'string'}}}},
  110. thinking_mode='thinking')),
  111. ]
  112. def load_reference(src):
  113. path = os.path.join(src, 'encoding', 'encoding.py')
  114. spec = importlib.util.spec_from_file_location('dsv41_encoding', path)
  115. mod = importlib.util.module_from_spec(spec)
  116. spec.loader.exec_module(mod)
  117. return mod
  118. def prepare_messages(case):
  119. msgs = json.loads(json.dumps(case['messages']))
  120. if case.get('tools'):
  121. msgs[0]['tools'] = case['tools']
  122. if case.get('response_format'):
  123. msgs[0]['response_format'] = case['response_format']
  124. return msgs
  125. def reference_render(enc, case):
  126. kwargs = {}
  127. if 'reasoning_effort' in case:
  128. kwargs['reasoning_effort'] = case['reasoning_effort']
  129. return enc.encode_messages(
  130. prepare_messages(case),
  131. thinking_mode=case['thinking_mode'],
  132. **kwargs,
  133. )
  134. def try_sglang_encoder():
  135. try:
  136. from sglang.srt.entrypoints.openai import encoding_dsv41
  137. except ImportError:
  138. return None
  139. install_encoder(encoding_dsv41)
  140. return encoding_dsv41
  141. def sglang_render(enc, case):
  142. kwargs = {}
  143. if 'reasoning_effort' in case:
  144. kwargs['reasoning_effort'] = case['reasoning_effort']
  145. return enc.encode_messages(
  146. prepare_messages(case),
  147. thinking_mode=case['thinking_mode'],
  148. **kwargs,
  149. )
  150. def check_reference_shape(name, case, prompt, enc):
  151. """Structural checks that do not require SGLang: thinking markers, effort, tools."""
  152. errors = []
  153. if case['thinking_mode'] == 'thinking':
  154. if '<think>' not in prompt:
  155. errors.append('thinking mode missing <think> generation header')
  156. effort = case.get('reasoning_effort', 'high')
  157. if isinstance(effort, str):
  158. budget = enc.REASONING_EFFORT_MAPPINGS[effort]
  159. else:
  160. budget = effort
  161. needle = f'Reasoning Effort: {budget} '
  162. if needle not in prompt:
  163. errors.append(f'missing {needle!r}')
  164. else:
  165. if 'Reasoning Effort:' in prompt:
  166. errors.append('chat mode should not render a reasoning-effort prefix')
  167. # Generation header in chat mode is </think>, not <think>
  168. if prompt.endswith('<think>'):
  169. errors.append('chat mode must not open <think> for generation')
  170. if '<|Assistant|></think>' not in prompt and not prompt.endswith('</think>'):
  171. # last turn should close thinking so the model answers in the clear
  172. if case['messages'][-1]['role'] == 'user':
  173. errors.append('chat mode user turn should end with </think> header')
  174. if case.get('tools'):
  175. if '## Tools' not in prompt:
  176. errors.append('tools missing ## Tools section')
  177. if '|DSML| calls' not in prompt:
  178. errors.append('tools missing DSML call wrapper')
  179. for tool in case['tools']:
  180. fn = tool.get('function') or tool
  181. if fn.get('name') and fn['name'] not in prompt:
  182. errors.append(f'tool schema missing name {fn["name"]!r}')
  183. if case.get('response_format') and '## Response Format:' not in prompt:
  184. errors.append('response_format missing schema block')
  185. return errors
  186. def main():
  187. ap = argparse.ArgumentParser()
  188. ap.add_argument('--src', default=DEFAULT_SRC)
  189. ap.add_argument('-v', '--verbose', action='store_true')
  190. args = ap.parse_args()
  191. enc_path = os.path.join(args.src, 'encoding', 'encoding.py')
  192. if not os.path.isfile(enc_path):
  193. print(f'skip: no reference encoder at {enc_path}', file=sys.stderr)
  194. sys.exit(0)
  195. ref = load_reference(args.src)
  196. assert ref.REASONING_EFFORT_MAPPINGS['low'] == 50
  197. assert ref.REASONING_EFFORT_MAPPINGS['high'] == 75
  198. assert ref.REASONING_EFFORT_MAPPINGS['max'] == 100
  199. assert PUBLISHER_REASONING_EFFORT['low'] == 50
  200. assert PUBLISHER_REASONING_EFFORT['high'] == 75
  201. sgl = try_sglang_encoder()
  202. if sgl is None:
  203. print('sglang encoding_dsv41 not importable; reference-shape checks only')
  204. else:
  205. print('comparing sglang encoding_dsv41 to publisher encoding.py '
  206. f'(effort map {dict(sgl.REASONING_EFFORT_MAPPINGS)})')
  207. failures = 0
  208. for name, case in CASES:
  209. prompt = reference_render(ref, case)
  210. errors = check_reference_shape(name, case, prompt, ref)
  211. if sgl is not None:
  212. try:
  213. got = sglang_render(sgl, case)
  214. except Exception as exc:
  215. errors.append(f'sglang encode raised {type(exc).__name__}: {exc}')
  216. got = None
  217. else:
  218. if got != prompt:
  219. diff = '\n'.join(difflib.unified_diff(
  220. prompt.splitlines(), got.splitlines(),
  221. 'publisher', 'sglang', lineterm='', n=1))
  222. errors.append('sglang prompt differs from publisher:\n' + diff[:2000])
  223. if errors:
  224. failures += 1
  225. print(f'[FAIL] {name}')
  226. for err in errors:
  227. for line in err.splitlines():
  228. print(' ' + line)
  229. else:
  230. print(f'[OK] {name}')
  231. if args.verbose:
  232. print(' ' + prompt.replace('\n', '\\n')[:220])
  233. print(f'\n{len(CASES) - failures}/{len(CASES)} cases passed')
  234. sys.exit(1 if failures else 0)
  235. if __name__ == '__main__':
  236. main()