|
1 | 1 | import pytest |
2 | | -import json |
3 | 2 | import requests |
4 | 3 | import os |
5 | | -from typing import Generator, List, Dict, Any, Optional |
6 | | -from dataclasses import dataclass |
7 | | - |
8 | | -# --- CONSTANTS & CONFIGURATION --- |
| 4 | +import json |
| 5 | +from typing import Dict, Any |
9 | 6 |
|
10 | | -# 基础 URL 修改为适配动态路径的前缀 |
11 | | -# 最终请求 URL 将拼接为: BASE_URL + "/" + {model_path} |
| 7 | +# --- CONFIGURATION --- |
12 | 8 | BASE_URL_PREFIX = "http://localhost:8000/siliconflow/models" |
13 | | - |
14 | 9 | API_KEY = os.getenv("SILICONFLOW_API_KEY", "test_api_key") |
15 | 10 |
|
16 | 11 | HEADERS = { |
|
20 | 15 | "X-DashScope-SSE": "enable", |
21 | 16 | } |
22 | 17 |
|
23 | | -# --- TOOL DEFINITIONS --- |
24 | | -TOOL_VECTOR_WEATHER = [ |
25 | | - { |
26 | | - "type": "function", |
27 | | - "function": { |
28 | | - "name": "get_current_weather", |
29 | | - "description": "Get the current weather in a given location", |
30 | | - "parameters": { |
31 | | - "type": "object", |
32 | | - "properties": { |
33 | | - "location": { |
34 | | - "type": "string", |
35 | | - "description": "The city and state, e.g. San Francisco, CA", |
36 | | - }, |
37 | | - "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}, |
38 | | - }, |
39 | | - "required": ["location"], |
40 | | - }, |
41 | | - }, |
42 | | - } |
43 | | -] |
| 18 | +# --- EXPECTED ERROR MESSAGES (COPIED FROM TABLE) --- |
| 19 | +# 定义预期错误常量,确保逐字对齐 |
| 20 | +ERR_MSG_TOP_P_TYPE = "<400> InternalError.Algo.InvalidParameter: Input should be a valid number, unable to parse string as a number: parameters.top_p" |
| 21 | +ERR_MSG_TOP_P_RANGE = "<400> InternalError.Algo.InvalidParameter: Range of top_p should be (0.0, 1.0]" |
| 22 | +ERR_MSG_TEMP_RANGE = "<400> InternalError.Algo.InvalidParameter: Temperature should be in [0.0, 2.0]" |
| 23 | +ERR_MSG_PARTIAL_THINKING_CONFLICT = "<400> InternalError.Algo.InvalidParameter: Partial mode is not supported when enable_thinking is true" |
| 24 | +# R1 不支持 enable_thinking 的报错 (注意:表格中该报错包含 Python 字典的字符串表示,需严格匹配引号) |
| 25 | +ERR_MSG_R1_THINKING = "Error code: 400 - {'code': 20015, 'message': 'Value error, current model does not support parameter `enable_thinking`.', 'data': None}" |
44 | 26 |
|
45 | 27 | # --- HELPERS --- |
46 | 28 |
|
47 | | -@dataclass |
48 | | -class SSEFrame: |
49 | | - """Formal representation of a Server-Sent Event frame for validation.""" |
50 | | - id: str |
51 | | - output: Dict[str, Any] |
52 | | - usage: Dict[str, Any] |
53 | | - request_id: str |
54 | | - |
55 | | -def parse_sse_stream(response: requests.Response) -> Generator[SSEFrame, None, None]: |
56 | | - """Parses the raw SSE stream line by line.""" |
57 | | - for line in response.iter_lines(): |
58 | | - if line: |
59 | | - decoded_line = line.decode("utf-8") |
60 | | - if decoded_line.startswith("data:"): |
61 | | - json_str = decoded_line[5:].strip() |
62 | | - try: |
63 | | - data = json.loads(json_str) |
64 | | - yield SSEFrame( |
65 | | - id=data.get("output", {}).get("choices", [{}])[0].get("id", "unknown"), |
66 | | - output=data.get("output", {}), |
67 | | - usage=data.get("usage", {}), |
68 | | - request_id=data.get("request_id", ""), |
69 | | - ) |
70 | | - except json.JSONDecodeError: |
71 | | - continue |
72 | | - |
73 | 29 | def make_request(payload: Dict[str, Any]) -> requests.Response: |
74 | | - """ |
75 | | - Helper to send POST request using the Dynamic Path URL structure. |
76 | | -
|
77 | | - Format: POST /siliconflow/models/{model_path} |
78 | | -
|
79 | | - It extracts the 'model' from the payload to construct the URL. |
80 | | - """ |
| 30 | + """Helper to send POST request using the Dynamic Path URL structure.""" |
81 | 31 | model_path = payload.get("model") |
82 | | - |
83 | | - if not model_path: |
84 | | - raise ValueError("Test payload must contain 'model' field for dynamic URL construction") |
85 | | - |
86 | | - # Construct the dynamic URL, e.g.: |
87 | | - # http://localhost:8000/siliconflow/models/deepseek-ai/DeepSeek-V3 |
88 | 32 | url = f"{BASE_URL_PREFIX}/{model_path}" |
89 | | - |
90 | | - # Send the request. Note: We keep 'model' in the json body as well, |
91 | | - # though the server mainly relies on the path parameter now. |
92 | 33 | return requests.post(url, headers=HEADERS, json=payload, stream=True) |
93 | 34 |
|
94 | | -# --- TEST SUITE --- |
95 | | - |
96 | | -class TestDynamicPathRouting: |
| 35 | +def assert_exact_error(response: requests.Response, expected_code_str: str, expected_message: str): |
97 | 36 | """ |
98 | | - 测试动态 URL 路由本身的正确性 |
| 37 | + 严格校验错误返回: |
| 38 | + 1. HTTP 状态码通常为 4xx 或 500 (根据表格,部分 4xx 业务错误可能返回 200 或 400,此处以解析 body 为主) |
| 39 | + 2. JSON body 中的 code 字段 |
| 40 | + 3. JSON body 中的 message 字段 (逐字匹配) |
99 | 41 | """ |
| 42 | + try: |
| 43 | + data = response.json() |
| 44 | + except Exception: |
| 45 | + pytest.fail(f"Response is not valid JSON: {response.text}") |
100 | 46 |
|
101 | | - def test_routing_basic_success(self): |
102 | | - """ |
103 | | - 测试标准的 URL 格式是否能通 |
104 | | - URL: .../deepseek-ai/DeepSeek-V3 |
105 | | - """ |
106 | | - payload = { |
107 | | - "model": "deepseek-ai/DeepSeek-V3", |
108 | | - "input": {"messages": [{"role": "user", "content": "Hello"}]}, |
109 | | - "parameters": {"max_tokens": 10} |
110 | | - } |
111 | | - response = make_request(payload) |
112 | | - assert response.status_code == 200 |
| 47 | + # 1. Check Error Code (e.g., 'InvalidParameter' or 'InternalError') |
| 48 | + actual_code = data.get("code") |
| 49 | + assert actual_code == expected_code_str, f"Error Code mismatch.\nExpected: {expected_code_str}\nActual: {actual_code}" |
113 | 50 |
|
114 | | - def test_routing_with_mapping(self): |
115 | | - """ |
116 | | - 测试服务端 ModelResolver 是否依然工作 |
117 | | - URL: .../pre-siliconflow/deepseek-v3 (会被映射到 upstream 的 deepseek-ai/DeepSeek-V3) |
118 | | - """ |
119 | | - payload = { |
120 | | - "model": "pre-siliconflow/deepseek-v3", |
121 | | - "input": {"messages": [{"role": "user", "content": "Test"}]}, |
122 | | - "parameters": {"max_tokens": 10} |
123 | | - } |
124 | | - response = make_request(payload) |
125 | | - assert response.status_code == 200 |
| 51 | + # 2. Check Error Message (Exact String Match) |
| 52 | + actual_message = data.get("message") |
| 53 | + assert actual_message == expected_message, f"Error Message mismatch.\nExpected: {expected_message}\nActual: {actual_message}" |
126 | 54 |
|
| 55 | +# --- TEST SUITE --- |
127 | 56 |
|
128 | | -class TestParameterValidation: |
129 | | - """ |
130 | | - 对应表格中参数校验相关的错误用例 (4xx Error Codes) |
131 | | - """ |
| 57 | +class TestStrictErrorValidation: |
132 | 58 |
|
133 | 59 | def test_invalid_parameter_type_top_p(self): |
134 | 60 | """ |
135 | | - Case: parameters.top_p 输入字符串 'a',预期返回 400 InvalidParameter。 |
| 61 | + 表格行: 4xx的报错请求 |
| 62 | + Input: top_p = "a" (string) |
| 63 | + Expected: InvalidParameter, <400> ... unable to parse string as a number |
136 | 64 | """ |
137 | 65 | payload = { |
138 | | - "model": "deepseek-ai/DeepSeek-V3", |
| 66 | + "model": "pre-siliconflow/deepseek-v3", |
139 | 67 | "input": {"messages": [{"role": "user", "content": "你好"}]}, |
140 | | - "parameters": {"top_p": "a"}, # Invalid type |
| 68 | + "parameters": {"top_p": "a"} |
141 | 69 | } |
142 | 70 | response = make_request(payload) |
143 | 71 |
|
144 | | - assert response.status_code == 400 |
145 | | - data = response.json() |
146 | | - assert "InvalidParameter" in data.get("code", "") or "InvalidParameter" in data.get("message", "") |
| 72 | + # 根据表格预期,HTTP Code 可能是 400 或 500,但我们主要校验 Body 内容 |
| 73 | + # 表格预期返回: code="InvalidParameter" |
| 74 | + assert_exact_error( |
| 75 | + response, |
| 76 | + expected_code_str="InvalidParameter", |
| 77 | + expected_message=ERR_MSG_TOP_P_TYPE |
| 78 | + ) |
147 | 79 |
|
148 | | - @pytest.mark.parametrize("top_p_value", [0, 0.0]) |
149 | | - def test_invalid_parameter_range_top_p(self, top_p_value): |
| 80 | + def test_invalid_parameter_range_top_p(self): |
150 | 81 | """ |
151 | | - Case: top_p取值范围 (0, 1.0]。测试边界值 0。 |
| 82 | + 表格行: pre-siliconflow-deepseek-v3.1 top_p取值范围(0,1.0] |
| 83 | + Input: top_p = 0 |
| 84 | + Expected: InvalidParameter, Range of top_p should be (0.0, 1.0] |
152 | 85 | """ |
153 | 86 | payload = { |
154 | | - "model": "deepseek-ai/DeepSeek-V3.1", |
| 87 | + "model": "pre-siliconflow/deepseek-v3.1", |
155 | 88 | "input": {"messages": [{"role": "user", "content": "你好"}]}, |
156 | | - "parameters": {"top_p": top_p_value}, |
| 89 | + "parameters": {"top_p": 0} |
157 | 90 | } |
158 | 91 | response = make_request(payload) |
159 | 92 |
|
160 | | - assert response.status_code == 400 |
161 | | - data = response.json() |
162 | | - assert "Range of top_p should be" in data.get("message", "") |
| 93 | + assert_exact_error( |
| 94 | + response, |
| 95 | + expected_code_str="InvalidParameter", |
| 96 | + expected_message=ERR_MSG_TOP_P_RANGE |
| 97 | + ) |
163 | 98 |
|
164 | 99 | def test_invalid_parameter_range_temperature(self): |
165 | 100 | """ |
166 | | - Case: temperature 取值范围 [0, 2]。测试值 2.1。 |
167 | | - """ |
168 | | - payload = { |
169 | | - "model": "deepseek-ai/DeepSeek-V3.1", |
170 | | - "input": {"messages": [{"role": "user", "content": "你好"}]}, |
171 | | - "parameters": {"temperature": 2.1}, |
172 | | - } |
173 | | - response = make_request(payload) |
174 | | - |
175 | | - assert response.status_code == 400 |
176 | | - data = response.json() |
177 | | - assert "Temperature should be in" in data.get("message", "") |
178 | | - |
179 | | - |
180 | | -class TestDeepSeekR1Specifics: |
181 | | - """ |
182 | | - 针对 R1 模型的特定测试用例 (Reasoning Models) |
183 | | - """ |
184 | | - |
185 | | - def test_r1_usage_structure(self): |
186 | | - """ |
187 | | - Case: R1 模型不应该返回 text_tokens,应该返回 reasoning_tokens。 |
188 | | - URL: .../deepseek-ai/DeepSeek-R1 |
| 101 | + 表格行: pre-siliconflow-deepseek-v3.1 取值范围 [0, 2) |
| 102 | + Input: temperature = 2.1 |
| 103 | + Expected: InvalidParameter, Temperature should be in [0.0, 2.0] |
189 | 104 | """ |
190 | 105 | payload = { |
191 | | - "model": "deepseek-ai/DeepSeek-R1", |
| 106 | + "model": "pre-siliconflow/deepseek-v3.1", |
192 | 107 | "input": {"messages": [{"role": "user", "content": "你好"}]}, |
193 | | - "parameters": {}, |
| 108 | + "parameters": {"temperature": 2.1} |
194 | 109 | } |
195 | 110 | response = make_request(payload) |
196 | | - assert response.status_code == 200 |
197 | | - |
198 | | - # 检查流式返回的最后一帧 Usage |
199 | | - frames = list(parse_sse_stream(response)) |
200 | | - assert len(frames) > 0 |
201 | | - final_usage = frames[-1].usage |
202 | | - |
203 | | - output_details = final_usage.get("output_tokens_details", {}) |
204 | | - assert output_details, "output_tokens_details missing" |
205 | 111 |
|
206 | | - # 验证不包含 text_tokens (R1 特性) |
207 | | - assert "text_tokens" not in output_details, "R1 usage should not contain text_tokens" |
208 | | - # 验证包含 reasoning_tokens |
209 | | - assert "reasoning_tokens" in output_details |
| 112 | + assert_exact_error( |
| 113 | + response, |
| 114 | + expected_code_str="InvalidParameter", |
| 115 | + expected_message=ERR_MSG_TEMP_RANGE |
| 116 | + ) |
210 | 117 |
|
211 | | - def test_r1_enable_thinking_parameter_error(self): |
| 118 | + def test_conflict_prefix_and_thinking(self): |
212 | 119 | """ |
213 | | - Case: R1 原生支持思考,显式传递 enable_thinking=True 应报错。 |
| 120 | + 表格行: 前缀续写...思考模式下...会报4xx |
| 121 | + Input: partial=True AND enable_thinking=True |
| 122 | + Expected: InvalidParameter, Partial mode is not supported when enable_thinking is true |
214 | 123 | """ |
215 | 124 | payload = { |
216 | | - "model": "deepseek-ai/DeepSeek-R1", |
217 | | - "input": {"messages": [{"role": "user", "content": "你好"}]}, |
218 | | - "parameters": {"enable_thinking": True}, |
219 | | - } |
220 | | - response = make_request(payload) |
221 | | - |
222 | | - assert response.status_code == 400 |
223 | | - data = response.json() |
224 | | - assert "does not support parameter" in data.get("message", "") |
225 | | - |
226 | | - |
227 | | -class TestAdvancedFeatures: |
228 | | - """ |
229 | | - 复杂场景:前缀续写、ToolCall 格式校验等 |
230 | | - """ |
231 | | - |
232 | | - def test_prefix_completion_thinking_conflict(self): |
233 | | - """ |
234 | | - Case: 思考模式下(enable_thinking=true),不支持前缀续写(partial=true)。 |
235 | | - """ |
236 | | - payload = { |
237 | | - "model": "deepseek-ai/DeepSeek-V3.2", |
| 125 | + "model": "pre-siliconflow/deepseek-v3.2", |
238 | 126 | "input": { |
239 | 127 | "messages": [ |
240 | 128 | {"role": "user", "content": "你好"}, |
241 | | - {"role": "assistant", "partial": True, "content": "你好,我是"}, |
| 129 | + {"role": "assistant", "partial": True, "content": "你好,我是"} |
242 | 130 | ] |
243 | 131 | }, |
244 | | - "parameters": {"enable_thinking": True}, |
| 132 | + "parameters": {"enable_thinking": True} |
245 | 133 | } |
246 | 134 | response = make_request(payload) |
247 | 135 |
|
248 | | - assert response.status_code == 400 |
249 | | - data = response.json() |
250 | | - assert "Partial mode is not supported when enable_thinking is true" in data.get("message", "") |
| 136 | + assert_exact_error( |
| 137 | + response, |
| 138 | + expected_code_str="InvalidParameter", |
| 139 | + expected_message=ERR_MSG_PARTIAL_THINKING_CONFLICT |
| 140 | + ) |
251 | 141 |
|
252 | | - def test_r1_tool_choice_conflict(self): |
| 142 | + def test_r1_enable_thinking_unsupported(self): |
253 | 143 | """ |
254 | | - Case: R1 模型下,enable_thinking (或原生 R1) 开启时,不支持具体的 tool_choice 字典绑定。 |
| 144 | + 表格行: r1传了enable_thinking报错 |
| 145 | + Input: model=deepseek-r1, enable_thinking=True |
| 146 | + Expected: InternalError, Error code: 400 - {'code': 20015...} |
255 | 147 | """ |
256 | 148 | payload = { |
257 | | - "model": "deepseek-ai/DeepSeek-R1", |
258 | | - "input": { |
259 | | - "messages": [ |
260 | | - {"role": "user", "content": "What is the weather like in Boston?"} |
261 | | - ] |
262 | | - }, |
263 | | - "parameters": { |
264 | | - "result_format": "message", |
265 | | - # R1 logic usually prevents enforcing a specific tool via dict when thinking is active |
266 | | - "tool_choice": { |
267 | | - "type": "function", |
268 | | - "function": {"name": "get_current_weather"}, |
269 | | - }, |
270 | | - "tools": TOOL_VECTOR_WEATHER, |
271 | | - }, |
| 149 | + "model": "pre-siliconflow/deepseek-r1", |
| 150 | + "input": {"messages": [{"role": "user", "content": "你好"}]}, |
| 151 | + "parameters": {"enable_thinking": True} |
272 | 152 | } |
273 | | - |
274 | 153 | response = make_request(payload) |
275 | 154 |
|
276 | | - # Expecting 400 because R1 + Specific Tool Choice is often restricted in this proxy logic |
277 | | - if response.status_code != 200: |
278 | | - assert response.status_code == 400 |
279 | | - error_data = response.json() |
280 | | - assert "DeepSeek R1 does not support specific tool_choice" in error_data.get("message", "") |
| 155 | + # 表格显示此处返回的是 InternalError,且 message 是上游透传回来的原始错误 |
| 156 | + assert_exact_error( |
| 157 | + response, |
| 158 | + expected_code_str="InternalError", |
| 159 | + expected_message=ERR_MSG_R1_THINKING |
| 160 | + ) |
281 | 161 |
|
282 | 162 | if __name__ == "__main__": |
283 | | - # 如果直接运行此脚本,可以使用 pytest 调起 |
284 | 163 | pytest.main(["-v", __file__]) |
0 commit comments