返回 CodeWhale
ds4_tests.rs
根目录 / crates / tui / src / client / ds4_tests.rs
1 use super::CodewhaleClient;
2 use super::chat::{parse_chat_message, parse_sse_chunk};
3 use crate::config::{Config, ProviderConfig, ProvidersConfig};
4 use anyhow::Result;
5 use codewhale_models::Role;
6 use codewhale_models::{ContentBlock, Delta, Message, MessageRequest, StreamEvent, Tool};
7 use serde_json::{Value, json};
8
9 fn ds4_client() -> CodewhaleClient {
10 let mut providers = ProvidersConfig::default();
11 providers.custom.insert(
12 "ds4".to_string(),
13 ProviderConfig {
14 kind: Some("openai-compatible".to_string()),
15 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
16 model: Some("deepseek-v4-flash".to_string()),
17 context_window: Some(100_000),
18 auth_mode: Some("none".to_string()),
19 ..Default::default()
20 },
21 );
22 CodewhaleClient::new(&Config {
23 provider: Some("ds4".to_string()),
24 providers: Some(providers),
25 ..Default::default()
26 })
27 .expect("DS4 client")
28 }
29
30 fn request(effort: &str) -> MessageRequest {
31 MessageRequest {
32 model: "deepseek-v4-flash".to_string(),
33 messages: vec![Message {
34 role: Role::User,
35 content: vec![ContentBlock::Text {
36 text: "hello".to_string(),
37 cache_control: None,
38 }],
39 }],
40 max_tokens: 128,
41 system: None,
42 tools: None,
43 tool_choice: None,
44 metadata: None,
45 thinking: None,
46 reasoning_effort: Some(effort.to_string()),
47 stream: None,
48 temperature: None,
49 top_p: None,
50 }
51 }
52
53 #[test]
54 fn named_ds4_route_uses_deepseek_reasoning_controls() -> Result<()> {
55 let client = ds4_client();
56
57 let off = client.prepare_outbound_request(request("off"), true)?;
58 assert_eq!(off.body["thinking"]["type"], "disabled");
59 assert!(off.body.get("reasoning_effort").is_none());
60
61 let max = client.prepare_outbound_request(request("max"), true)?;
62 assert_eq!(max.body["thinking"]["type"], "enabled");
63 assert_eq!(max.body["reasoning_effort"], "max");
64 Ok(())
65 }
66
67 #[test]
68 fn named_ds4_route_removes_unsupported_strict_tool_flag() -> Result<()> {
69 let client = ds4_client();
70 let mut request = request("high");
71 request.tools = Some(vec![Tool {
72 tool_type: Some("function".to_string()),
73 name: "read_file".to_string(),
74 description: "Read one file".to_string(),
75 input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}),
76 allowed_callers: None,
77 defer_loading: None,
78 input_examples: None,
79 strict: Some(true),
80 cache_control: None,
81 }]);
82
83 let outbound = client.prepare_outbound_request(request, true)?;
84 assert!(
85 outbound.body["tools"][0]["function"]
86 .get("strict")
87 .is_none()
88 );
89 Ok(())
90 }
91
92 #[test]
93 fn non_streaming_fixture_preserves_tool_call_and_usage() -> Result<()> {
94 // Recorded DS4/OpenAI-compatible response shape. Keep this fixture
95 // provider-free: DS4 should stay on the shared parser contract.
96 let response = parse_chat_message(&json!({
97 "id": "chatcmpl-ds4-tool",
98 "model": "deepseek-v4-flash",
99 "choices": [{
100 "index": 0,
101 "message": {
102 "role": "assistant",
103 "content": null,
104 "tool_calls": [{
105 "index": 0,
106 "id": "call_ds4_0",
107 "type": "function",
108 "function": {
109 "name": "read_file",
110 "arguments": "{\"path\":\"src/main.rs\"}"
111 }
112 }]
113 },
114 "finish_reason": "tool_calls"
115 }],
116 "usage": {
117 "prompt_tokens": 23,
118 "completion_tokens": 7,
119 "total_tokens": 30
120 }
121 }))?;
122
123 assert!(matches!(
124 response.content.as_slice(),
125 [ContentBlock::ToolUse { id, name, input, ..}]
126 if id == "call_ds4_0"
127 && name == "read_file"
128 && input == &json!({"path": "src/main.rs"})
129 ));
130 assert_eq!(response.usage.input_tokens, 23);
131 assert_eq!(response.usage.output_tokens, 7);
132 Ok(())
133 }
134
135 #[test]
136 fn malformed_tool_arguments_remain_visible_for_feedback() -> Result<()> {
137 let response = parse_chat_message(&json!({
138 "id": "chatcmpl-ds4-malformed",
139 "model": "deepseek-v4-flash",
140 "choices": [{
141 "message": {
142 "role": "assistant",
143 "content": null,
144 "tool_calls": [{
145 "id": "call_bad",
146 "type": "function",
147 "function": {"name": "read_file", "arguments": "{bad json"}
148 }]
149 },
150 "finish_reason": "tool_calls"
151 }]
152 }))?;
153
154 assert!(matches!(
155 response.content.as_slice(),
156 [ContentBlock::ToolUse { input: Value::String(raw), ..}] if raw == "{bad json"
157 ));
158 Ok(())
159 }
160
161 #[test]
162 fn replay_placeholder_echo_is_dropped_from_ingest() {
163 // GLM-5.x mirrors the serializer's outgoing `(reasoning omitted)`
164 // placeholder back as a live reasoning delta. Ingesting it persisted a
165 // fake thinking block into the transcript and rendered it live. Only the
166 // exact transport echo is dropped; genuine reasoning passes through.
167 let mut content_index = 0;
168 let mut text_started = false;
169 let mut thinking_started = false;
170 let mut tool_indices = std::collections::HashMap::new();
171 let mut reasoning_detail_buffers = std::collections::HashMap::new();
172 let chunks = [
173 json!({
174 "choices": [{
175 "index": 0,
176 "delta": {"reasoning_content": "(reasoning omitted)"},
177 "finish_reason": null
178 }]
179 }),
180 json!({
181 "choices": [{
182 "index": 0,
183 "delta": {"reasoning_content": " (reasoning omitted) \n"},
184 "finish_reason": null
185 }]
186 }),
187 json!({
188 "choices": [{
189 "index": 0,
190 "delta": {"reasoning_content": "the user wants a table"},
191 "finish_reason": null
192 }]
193 }),
194 ];
195 let events = chunks
196 .iter()
197 .flat_map(|chunk| {
198 parse_sse_chunk(
199 chunk,
200 &mut content_index,
201 &mut text_started,
202 &mut thinking_started,
203 &mut tool_indices,
204 &mut reasoning_detail_buffers,
205 true,
206 )
207 })
208 .collect::<Vec<_>>();
209 let thinking_deltas = events
210 .iter()
211 .filter_map(|event| match event {
212 StreamEvent::ContentBlockDelta {
213 delta: Delta::ThinkingDelta { thinking },
214 ..
215 } => Some(thinking.as_str()),
216 _ => None,
217 })
218 .collect::<Vec<_>>();
219 assert_eq!(
220 thinking_deltas,
221 vec!["the user wants a table"],
222 "placeholder echoes must be dropped, real reasoning kept"
223 );
224 }
225
226 #[test]
227 fn streaming_fixture_accepts_delayed_tool_arguments_and_usage_tail() {
228 let mut content_index = 0;
229 let mut text_started = false;
230 let mut thinking_started = false;
231 let mut tool_indices = std::collections::HashMap::new();
232 let mut reasoning_detail_buffers = std::collections::HashMap::new();
233 let chunks = [
234 json!({
235 "choices": [{
236 "index": 0,
237 "delta": {
238 "tool_calls": [{
239 "index": 0,
240 "id": "call_ds4_0",
241 "type": "function",
242 "function": {"name": "read_file", "arguments": "{\"path\":"}
243 }]
244 },
245 "finish_reason": null
246 }]
247 }),
248 json!({
249 "choices": [{
250 "index": 0,
251 "delta": {
252 "tool_calls": [{
253 "index": 0,
254 "function": {"arguments": "\"src/main.rs\"}"}
255 }]
256 },
257 "finish_reason": "tool_calls"
258 }]
259 }),
260 json!({
261 "choices": [],
262 "usage": {"prompt_tokens": 23, "completion_tokens": 7, "total_tokens": 30}
263 }),
264 ];
265
266 let events = chunks
267 .iter()
268 .flat_map(|chunk| {
269 parse_sse_chunk(
270 chunk,
271 &mut content_index,
272 &mut text_started,
273 &mut thinking_started,
274 &mut tool_indices,
275 &mut reasoning_detail_buffers,
276 false,
277 )
278 })
279 .collect::<Vec<_>>();
280
281 let argument_deltas = events
282 .iter()
283 .filter_map(|event| match event {
284 StreamEvent::ContentBlockDelta {
285 delta: Delta::InputJsonDelta { partial_json },
286 ..
287 } => Some(partial_json.as_str()),
288 _ => None,
289 })
290 .collect::<String>();
291 assert_eq!(argument_deltas, "{\"path\":\"src/main.rs\"}");
292 assert!(events.iter().any(|event| matches!(
293 event,
294 StreamEvent::MessageDelta { usage: Some(usage), .. }
295 if usage.input_tokens == 23 && usage.output_tokens == 7
296 )));
297 }
298
298 lines RUST