11import pytest
22import json
33import requests
4- from typing import Generator , List , Dict , Any
5- from dataclasses import dataclass
64import os
5+ from typing import Generator , List , Dict , Any , Optional
6+ from dataclasses import dataclass
77
88# --- CONSTANTS & CONFIGURATION ---
99
10- # 修改为动态 URL 的基础路径
11- GATEWAY_BASE_URL = "http://localhost:8000/siliconflow/models"
10+ # 基础 URL 修改为适配动态路径的前缀
11+ # 最终请求 URL 将拼接为: BASE_URL + "/" + {model_path}
12+ BASE_URL_PREFIX = "http://localhost:8000/siliconflow/models"
1213
1314API_KEY = os .getenv ("SILICONFLOW_API_KEY" , "test_api_key" )
1415
4344
4445# --- HELPERS ---
4546
46-
4747@dataclass
4848class SSEFrame :
4949 """Formal representation of a Server-Sent Event frame for validation."""
50-
5150 id : str
5251 output : Dict [str , Any ]
5352 usage : Dict [str , Any ]
5453 request_id : str
5554
56-
5755def parse_sse_stream (response : requests .Response ) -> Generator [SSEFrame , None , None ]:
58- """Parses the raw SSE stream."""
56+ """Parses the raw SSE stream line by line ."""
5957 for line in response .iter_lines ():
6058 if line :
6159 decoded_line = line .decode ("utf-8" )
@@ -64,41 +62,68 @@ def parse_sse_stream(response: requests.Response) -> Generator[SSEFrame, None, N
6462 try :
6563 data = json .loads (json_str )
6664 yield SSEFrame (
67- id = data .get ("output" , {})
68- .get ("choices" , [{}])[0 ]
69- .get ("id" , "unknown" ),
65+ id = data .get ("output" , {}).get ("choices" , [{}])[0 ].get ("id" , "unknown" ),
7066 output = data .get ("output" , {}),
7167 usage = data .get ("usage" , {}),
7268 request_id = data .get ("request_id" , "" ),
7369 )
7470 except json .JSONDecodeError :
7571 continue
7672
77-
78- def make_request (payload : Dict [str , Any ]):
73+ def make_request (payload : Dict [str , Any ]) -> requests .Response :
7974 """
80- Helper to send POST request using the Dynamic Path URL.
81- Extracts 'model' from payload to construct the URL:
82- POST /siliconflow/models/{model_path}
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.
8380 """
84- # 提取模型名称用于构建 URL
8581 model_path = payload .get ("model" )
8682
8783 if not model_path :
88- raise ValueError (
89- "Payload must contain 'model' field for dynamic URL construction"
90- )
84+ raise ValueError ("Test payload must contain 'model' field for dynamic URL construction" )
9185
92- # 构建动态 URL
93- # 例如: http://localhost:8000/siliconflow/models/pre-siliconflow/deepseek-v3
94- url = f"{ GATEWAY_BASE_URL } /{ model_path } "
86+ # Construct the dynamic URL, e.g.:
87+ # http://localhost:8000/siliconflow/models/deepseek-ai/DeepSeek-V3
88+ url = f"{ BASE_URL_PREFIX } /{ model_path } "
9589
96- # 发送请求 (Payload 中保留 model 字段通常没问题,服务器端代码会再次处理或忽略)
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.
9792 return requests .post (url , headers = HEADERS , json = payload , stream = True )
9893
99-
10094# --- TEST SUITE ---
10195
96+ class TestDynamicPathRouting :
97+ """
98+ 测试动态 URL 路由本身的正确性
99+ """
100+
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
113+
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
126+
102127
103128class TestParameterValidation :
104129 """
@@ -108,51 +133,40 @@ class TestParameterValidation:
108133 def test_invalid_parameter_type_top_p (self ):
109134 """
110135 Case: parameters.top_p 输入字符串 'a',预期返回 400 InvalidParameter。
111- URL: /siliconflow/models/pre-siliconflow/deepseek-v3
112136 """
113137 payload = {
114- "model" : "pre-siliconflow/deepseek-v3 " ,
138+ "model" : "deepseek-ai/DeepSeek-V3 " ,
115139 "input" : {"messages" : [{"role" : "user" , "content" : "你好" }]},
116140 "parameters" : {"top_p" : "a" }, # Invalid type
117141 }
118142 response = make_request (payload )
119143
120- # 验证状态码不应为 500
121- assert (
122- response .status_code != 500
123- ), "Should not return 500 for invalid parameter type"
124144 assert response .status_code == 400
125-
126145 data = response .json ()
127- assert "InvalidParameter" in data .get (
128- "code" , ""
129- ) or "InvalidParameter" in data .get ("message" , "" )
146+ assert "InvalidParameter" in data .get ("code" , "" ) or "InvalidParameter" in data .get ("message" , "" )
130147
131148 @pytest .mark .parametrize ("top_p_value" , [0 , 0.0 ])
132149 def test_invalid_parameter_range_top_p (self , top_p_value ):
133150 """
134- Case: pre-siliconflow-deepseek-v3.1 top_p取值范围 (0, 1.0]。
135- 测试边界值 0,预期报错。
136- URL: /siliconflow/models/pre-siliconflow/deepseek-v3.1
151+ Case: top_p取值范围 (0, 1.0]。测试边界值 0。
137152 """
138153 payload = {
139- "model" : "pre-siliconflow/deepseek-v3 .1" ,
154+ "model" : "deepseek-ai/DeepSeek-V3 .1" ,
140155 "input" : {"messages" : [{"role" : "user" , "content" : "你好" }]},
141156 "parameters" : {"top_p" : top_p_value },
142157 }
143158 response = make_request (payload )
144159
145- assert response .status_code == 400 , f"top_p= { top_p_value } should be invalid"
160+ assert response .status_code == 400
146161 data = response .json ()
147162 assert "Range of top_p should be" in data .get ("message" , "" )
148163
149164 def test_invalid_parameter_range_temperature (self ):
150165 """
151- Case: pre-siliconflow-deepseek-v3.1 temperature 取值范围 [0, 2]。
152- 测试值 2.1,预期报错。
166+ Case: temperature 取值范围 [0, 2]。测试值 2.1。
153167 """
154168 payload = {
155- "model" : "pre-siliconflow/deepseek-v3 .1" ,
169+ "model" : "deepseek-ai/DeepSeek-V3 .1" ,
156170 "input" : {"messages" : [{"role" : "user" , "content" : "你好" }]},
157171 "parameters" : {"temperature" : 2.1 },
158172 }
@@ -165,17 +179,16 @@ def test_invalid_parameter_range_temperature(self):
165179
166180class TestDeepSeekR1Specifics :
167181 """
168- 针对 R1 模型的特定测试用例
182+ 针对 R1 模型的特定测试用例 (Reasoning Models)
169183 """
170184
171185 def test_r1_usage_structure (self ):
172186 """
173- Case: .usage.output_tokens_details 该路径下不应该返回 text_tokens 字段。
174- R1 模型推理侧可能没有 text_tokens。
175- URL: /siliconflow/models/pre-siliconflow/deepseek-r1
187+ Case: R1 模型不应该返回 text_tokens,应该返回 reasoning_tokens。
188+ URL: .../deepseek-ai/DeepSeek-R1
176189 """
177190 payload = {
178- "model" : "pre-siliconflow/deepseek-r1 " ,
191+ "model" : "deepseek-ai/DeepSeek-R1 " ,
179192 "input" : {"messages" : [{"role" : "user" , "content" : "你好" }]},
180193 "parameters" : {},
181194 }
@@ -188,22 +201,19 @@ def test_r1_usage_structure(self):
188201 final_usage = frames [- 1 ].usage
189202
190203 output_details = final_usage .get ("output_tokens_details" , {})
191- # 验证 output_tokens_details 存在
192204 assert output_details , "output_tokens_details missing"
193- # 验证不包含 text_tokens (根据表格描述这是预期行为)
194- assert (
195- "text_tokens" not in output_details
196- ), "R1 usage should not contain text_tokens"
205+
206+ # 验证不包含 text_tokens (R1 特性)
207+ assert "text_tokens" not in output_details , "R1 usage should not contain text_tokens"
197208 # 验证包含 reasoning_tokens
198209 assert "reasoning_tokens" in output_details
199210
200211 def test_r1_enable_thinking_parameter_error (self ):
201212 """
202- Case: r1传了 enable_thinking 报错。
203- 预期: 400 Value error, current model does not support parameter `enable_thinking`.
213+ Case: R1 原生支持思考,显式传递 enable_thinking=True 应报错。
204214 """
205215 payload = {
206- "model" : "pre-siliconflow/deepseek-r1 " ,
216+ "model" : "deepseek-ai/DeepSeek-R1 " ,
207217 "input" : {"messages" : [{"role" : "user" , "content" : "你好" }]},
208218 "parameters" : {"enable_thinking" : True },
209219 }
@@ -216,17 +226,15 @@ def test_r1_enable_thinking_parameter_error(self):
216226
217227class TestAdvancedFeatures :
218228 """
219- 复杂场景:前缀续写、历史消息包含 ToolCall 等
229+ 复杂场景:前缀续写、ToolCall 格式校验等
220230 """
221231
222232 def test_prefix_completion_thinking_conflict (self ):
223233 """
224234 Case: 思考模式下(enable_thinking=true),不支持前缀续写(partial=true)。
225- 预期返回: 400 InvalidParameter.
226- URL: /siliconflow/models/pre-siliconflow/deepseek-v3.2
227235 """
228236 payload = {
229- "model" : "pre-siliconflow/deepseek-v3 .2" ,
237+ "model" : "deepseek-ai/DeepSeek-V3 .2" ,
230238 "input" : {
231239 "messages" : [
232240 {"role" : "user" , "content" : "你好" },
@@ -239,69 +247,22 @@ def test_prefix_completion_thinking_conflict(self):
239247
240248 assert response .status_code == 400
241249 data = response .json ()
242- assert "Partial mode is not supported when enable_thinking is true" in data .get (
243- "message" , ""
244- )
245-
246- def test_history_with_tool_calls (self ):
247- """
248- Case: 3.1和3.2 message中包含历史 tool_call 调用信息曾报 5xx。
249- 预期: 200 OK。
250- """
251- payload = {
252- "model" : "pre-siliconflow/deepseek-v3.2" ,
253- "input" : {
254- "messages" : [
255- {
256- "role" : "system" ,
257- "content" : "你是一个为智能助手。请使用简洁、自然、适合朗读的中文回答" ,
258- },
259- {"role" : "user" , "content" : "外部轴设置" },
260- {
261- "role" : "assistant" ,
262- "tool_calls" : [
263- {
264- "function" : {
265- "arguments" : '{"input_text": "外部轴设置"}' ,
266- "name" : "KB20250625001" ,
267- },
268- "id" : "call_6478091069c2448b83f38e" ,
269- "type" : "function" ,
270- }
271- ],
272- },
273- {
274- "role" : "tool" ,
275- "content" : "界面用于用户进行快速配置。" ,
276- "tool_call_id" : "call_6478091069c2448b83f38e" ,
277- },
278- ]
279- },
280- "parameters" : {"enable_thinking" : True },
281- }
282- response = make_request (payload )
250+ assert "Partial mode is not supported when enable_thinking is true" in data .get ("message" , "" )
283251
284- # 核心验证:不能崩 (500)
285- assert (
286- response .status_code != 500
287- ), "Server returned 500 for history with tool calls"
288- assert response .status_code == 200
289-
290- def test_r1_tool_call_format_wrapping (self ):
252+ def test_r1_tool_choice_conflict (self ):
291253 """
292- Case: Error code: 400, _sse_http_status: 500 (Wrappping error).
293- Input should be 'none', 'auto' or 'required'.
294- 验证 R1 对 result_format='message' 和 tool_choice 的组合处理。
254+ Case: R1 模型下,enable_thinking (或原生 R1) 开启时,不支持具体的 tool_choice 字典绑定。
295255 """
296256 payload = {
297- "model" : "pre-siliconflow/deepseek-r1 " ,
257+ "model" : "deepseek-ai/DeepSeek-R1 " ,
298258 "input" : {
299259 "messages" : [
300260 {"role" : "user" , "content" : "What is the weather like in Boston?" }
301261 ]
302262 },
303263 "parameters" : {
304264 "result_format" : "message" ,
265+ # R1 logic usually prevents enforcing a specific tool via dict when thinking is active
305266 "tool_choice" : {
306267 "type" : "function" ,
307268 "function" : {"name" : "get_current_weather" },
@@ -312,7 +273,12 @@ def test_r1_tool_call_format_wrapping(self):
312273
313274 response = make_request (payload )
314275
276+ # Expecting 400 because R1 + Specific Tool Choice is often restricted in this proxy logic
315277 if response .status_code != 200 :
278+ assert response .status_code == 400
316279 error_data = response .json ()
317- assert response .status_code != 500
318- assert error_data .get ("code" ) != "InternalError"
280+ assert "DeepSeek R1 does not support specific tool_choice" in error_data .get ("message" , "" )
281+
282+ if __name__ == "__main__" :
283+ # 如果直接运行此脚本,可以使用 pytest 调起
284+ pytest .main (["-v" , __file__ ])
0 commit comments