返回 DeepSeek-TUI-2026
models.rs
根目录 / crates / tui / src / models.rs
1 //! API request/response models for `DeepSeek` and OpenAI-compatible endpoints.
2
3 use serde::{Deserialize, Serialize};
4
5 /// Context window used only for legacy DeepSeek model IDs that do not name a
6 /// newer V4 alias and do not carry an explicit `*k` suffix.
7 pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
8 pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000;
9 /// Last-resort compaction trigger when [`context_window_for_model`] returns
10 /// `None` (an unrecognised model id). v0.8.11 raised this from `50_000` to
11 /// `102_400` (80% of [`LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS`]) so unknown
12 /// models inherit the same late-trigger discipline as V4 instead of paying
13 /// the prefix-cache hit at 5% of the V4 window. Known DeepSeek / Claude
14 /// models resolve to their own scaled value via
15 /// [`compaction_threshold_for_model`] (#664).
16 pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400;
17 const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
18
19 // === Core Message Types ===
20
21 /// Request payload for sending a message to the API.
22 #[derive(Debug, Serialize, Deserialize, Clone)]
23 pub struct MessageRequest {
24 pub model: String,
25 pub messages: Vec<Message>,
26 pub max_tokens: u32,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub system: Option<SystemPrompt>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub tools: Option<Vec<Tool>>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub tool_choice: Option<serde_json::Value>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub metadata: Option<serde_json::Value>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub thinking: Option<serde_json::Value>,
37 /// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
38 /// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub reasoning_effort: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub stream: Option<bool>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub temperature: Option<f32>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub top_p: Option<f32>,
47 }
48
49 /// System prompt representation (plain text or structured blocks).
50 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
51 #[serde(untagged)]
52 pub enum SystemPrompt {
53 Text(String),
54 Blocks(Vec<SystemBlock>),
55 }
56
57 /// A structured system prompt block.
58 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
59 pub struct SystemBlock {
60 #[serde(rename = "type")]
61 pub block_type: String,
62 pub text: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub cache_control: Option<CacheControl>,
65 }
66
67 /// A chat message with role and content blocks.
68 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
69 pub struct Message {
70 pub role: String,
71 pub content: Vec<ContentBlock>,
72 }
73
74 /// A single content block inside a message.
75 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
76 #[serde(tag = "type")]
77 pub enum ContentBlock {
78 #[serde(rename = "text")]
79 Text {
80 text: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 cache_control: Option<CacheControl>,
83 },
84 #[serde(rename = "thinking")]
85 Thinking { thinking: String },
86 #[serde(rename = "tool_use")]
87 ToolUse {
88 id: String,
89 name: String,
90 input: serde_json::Value,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 caller: Option<ToolCaller>,
93 },
94 #[serde(rename = "tool_result")]
95 ToolResult {
96 tool_use_id: String,
97 content: String,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 is_error: Option<bool>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 content_blocks: Option<Vec<serde_json::Value>>,
102 },
103 #[serde(rename = "server_tool_use")]
104 ServerToolUse {
105 id: String,
106 name: String,
107 input: serde_json::Value,
108 },
109 #[serde(rename = "tool_search_tool_result")]
110 ToolSearchToolResult {
111 tool_use_id: String,
112 content: serde_json::Value,
113 },
114 #[serde(rename = "code_execution_tool_result")]
115 CodeExecutionToolResult {
116 tool_use_id: String,
117 content: serde_json::Value,
118 },
119 }
120
121 /// Cache control metadata for tool definitions and blocks.
122 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
123 pub struct CacheControl {
124 #[serde(rename = "type")]
125 pub cache_type: String,
126 }
127
128 /// Metadata describing who invoked a tool call.
129 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
130 pub struct ToolCaller {
131 #[serde(rename = "type")]
132 pub caller_type: String,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub tool_id: Option<String>,
135 }
136
137 /// Tool definition exposed to the model.
138 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
139 pub struct Tool {
140 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
141 pub tool_type: Option<String>,
142 pub name: String,
143 pub description: String,
144 pub input_schema: serde_json::Value,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub allowed_callers: Option<Vec<String>>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub defer_loading: Option<bool>,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub input_examples: Option<Vec<serde_json::Value>>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub strict: Option<bool>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub cache_control: Option<CacheControl>,
155 }
156
157 /// Container metadata for code-execution style server tools.
158 #[derive(Debug, Serialize, Deserialize, Clone)]
159 pub struct ContainerInfo {
160 pub id: String,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub expires_at: Option<String>,
163 }
164
165 /// Server-side tool usage counters.
166 #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
167 pub struct ServerToolUsage {
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub code_execution_requests: Option<u32>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub tool_search_requests: Option<u32>,
172 }
173
174 /// Response payload for a message request.
175 #[derive(Debug, Serialize, Deserialize, Clone)]
176 pub struct MessageResponse {
177 pub id: String,
178 pub r#type: String,
179 pub role: String,
180 pub content: Vec<ContentBlock>,
181 pub model: String,
182 pub stop_reason: Option<String>,
183 pub stop_sequence: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub container: Option<ContainerInfo>,
186 pub usage: Usage,
187 }
188
189 /// Token usage metadata for a response.
190 #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
191 pub struct Usage {
192 pub input_tokens: u32,
193 pub output_tokens: u32,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub prompt_cache_hit_tokens: Option<u32>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub prompt_cache_miss_tokens: Option<u32>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub reasoning_tokens: Option<u32>,
200 /// Approximate input tokens spent re-sending prior `reasoning_content`
201 /// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling
202 /// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at
203 /// ~4 chars/token from the outgoing request body, before the model sees it.
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub reasoning_replay_tokens: Option<u32>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub server_tool_use: Option<ServerToolUsage>,
208 }
209
210 /// Map known models to their approximate context window sizes.
211 #[must_use]
212 pub fn context_window_for_model(model: &str) -> Option<u32> {
213 let lower = model.to_lowercase();
214 // Unknown legacy DeepSeek model IDs default to 128K unless an explicit
215 // *k suffix is present. DeepSeek-V4 family and current compatibility
216 // aliases ship with a 1M context window.
217 if lower.contains("deepseek") {
218 if let Some(explicit_window) = deepseek_context_window_hint(&lower) {
219 return Some(explicit_window);
220 }
221 if lower.contains("v4") {
222 return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
223 }
224 return Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS);
225 }
226 if lower.contains("claude") {
227 return Some(200_000);
228 }
229 None
230 }
231
232 fn deepseek_context_window_hint(model_lower: &str) -> Option<u32> {
233 let bytes = model_lower.as_bytes();
234 let mut i = 0usize;
235 while i < bytes.len() {
236 if bytes[i].is_ascii_digit() {
237 let start = i;
238 while i < bytes.len() && bytes[i].is_ascii_digit() {
239 i += 1;
240 }
241 if i >= bytes.len() || bytes[i] != b'k' {
242 continue;
243 }
244
245 let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
246 let after_ok = i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_alphanumeric();
247 if !before_ok || !after_ok {
248 continue;
249 }
250
251 if let Ok(kilo_tokens) = model_lower[start..i].parse::<u32>()
252 && (8..=1024).contains(&kilo_tokens)
253 {
254 return Some(kilo_tokens.saturating_mul(1000));
255 }
256 } else {
257 i += 1;
258 }
259 }
260 None
261 }
262
263 /// Derive a compaction token threshold from model context window.
264 ///
265 /// Keeps headroom for tool outputs and assistant completion by defaulting to 80%
266 /// of known context windows.
267 #[must_use]
268 pub fn compaction_threshold_for_model(model: &str) -> usize {
269 let Some(window) = context_window_for_model(model) else {
270 return DEFAULT_COMPACTION_TOKEN_THRESHOLD;
271 };
272
273 let threshold = (u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT)) / 100;
274 usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
275 }
276
277 /// Compaction threshold keyed by model and caller-supplied effort tier.
278 ///
279 /// Replacement-style compaction rewrites the stable prefix, which works against
280 /// DeepSeek V4 prefix-cache economics. Reasoning effort must not lower V4's
281 /// automatic replacement threshold; V4-family models use the same late
282 /// 80%-of-window guard as `compaction_threshold_for_model`.
283 #[must_use]
284 pub fn compaction_threshold_for_model_and_effort(
285 model: &str,
286 _reasoning_effort: Option<&str>,
287 ) -> usize {
288 compaction_threshold_for_model(model)
289 }
290
291 // === Streaming Structures ===
292
293 #[allow(dead_code)]
294 #[derive(Debug, Deserialize, Clone)]
295 #[serde(tag = "type")]
296 /// Streaming event types for SSE responses.
297 pub enum StreamEvent {
298 #[serde(rename = "message_start")]
299 MessageStart { message: MessageResponse },
300 #[serde(rename = "content_block_start")]
301 ContentBlockStart {
302 index: u32,
303 content_block: ContentBlockStart,
304 },
305 #[serde(rename = "content_block_delta")]
306 ContentBlockDelta { index: u32, delta: Delta },
307 #[serde(rename = "content_block_stop")]
308 ContentBlockStop { index: u32 },
309 #[serde(rename = "message_delta")]
310 MessageDelta {
311 delta: MessageDelta,
312 usage: Option<Usage>,
313 },
314 #[serde(rename = "message_stop")]
315 MessageStop,
316 #[serde(rename = "ping")]
317 Ping,
318 }
319
320 #[allow(dead_code)]
321 #[derive(Debug, Deserialize, Clone)]
322 #[serde(tag = "type")]
323 /// Content block types used in streaming starts.
324 pub enum ContentBlockStart {
325 #[serde(rename = "text")]
326 Text { text: String },
327 #[serde(rename = "thinking")]
328 Thinking { thinking: String },
329 #[serde(rename = "tool_use")]
330 ToolUse {
331 id: String,
332 name: String,
333 input: serde_json::Value, // usually empty or partial
334 #[serde(skip_serializing_if = "Option::is_none")]
335 caller: Option<ToolCaller>,
336 },
337 #[serde(rename = "server_tool_use")]
338 ServerToolUse {
339 id: String,
340 name: String,
341 input: serde_json::Value,
342 },
343 }
344
345 // Variant names match legacy streaming spec, suppressing style warning
346 #[allow(clippy::enum_variant_names)]
347 #[derive(Debug, Deserialize, Clone)]
348 #[serde(tag = "type")]
349 /// Delta events emitted during streaming responses.
350 pub enum Delta {
351 #[serde(rename = "text_delta")]
352 TextDelta { text: String },
353 #[serde(rename = "thinking_delta")]
354 ThinkingDelta { thinking: String },
355 #[serde(rename = "input_json_delta")]
356 InputJsonDelta { partial_json: String },
357 }
358
359 #[allow(dead_code)]
360 #[derive(Debug, Deserialize, Clone)]
361 /// Delta payload for message-level updates.
362 pub struct MessageDelta {
363 pub stop_reason: Option<String>,
364 pub stop_sequence: Option<String>,
365 }
366
367 #[cfg(test)]
368 mod tests {
369 use super::*;
370
371 #[test]
372 fn v4_snapshots_preserve_context_window() {
373 // v-series snapshots get 1M context since they contain "v4"
374 assert_eq!(
375 context_window_for_model("deepseek-v4-flash-20260423"),
376 Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
377 );
378 assert_eq!(
379 context_window_for_model("deepseek-v4-pro-20260423"),
380 Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
381 );
382 }
383
384 #[test]
385 fn unknown_legacy_deepseek_models_map_to_128k_context_window() {
386 assert_eq!(
387 context_window_for_model("deepseek-coder"),
388 Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
389 );
390 assert_eq!(
391 context_window_for_model("deepseek-v3.2-0324"),
392 Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
393 );
394 }
395
396 #[test]
397 fn deepseek_v4_models_map_to_1m_context_window() {
398 assert_eq!(
399 context_window_for_model("deepseek-v4-pro"),
400 Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
401 );
402 assert_eq!(
403 context_window_for_model("deepseek-v4-flash"),
404 Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
405 );
406 assert_eq!(
407 context_window_for_model("deepseek-ai/deepseek-v4-pro"),
408 Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
409 );
410 }
411
412 #[test]
413 fn deepseek_models_with_k_suffix_use_hint() {
414 assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000));
415 assert_eq!(
416 context_window_for_model("deepseek-v3.2-256k-preview"),
417 Some(256_000)
418 );
419 assert_eq!(
420 context_window_for_model("deepseek-v3.2-2k-preview"),
421 Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
422 );
423 }
424
425 #[test]
426 fn compaction_threshold_scales_with_context_window() {
427 assert_eq!(
428 compaction_threshold_for_model("deepseek-v3.2-128k"),
429 102_400
430 );
431 // v0.8.11 (#664): unknown-model fallback also resolves to 80% of
432 // `LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS` (128K legacy DeepSeek
433 // fallback) — same late-trigger discipline as the V4 path. Was
434 // `50_000` pre-v0.8.11; that hardcoded value compacted at ~5% of a
435 // 1M window when model detection silently fell through, which is
436 // exactly the prefix-cache-burning behaviour we're getting away from.
437 assert_eq!(compaction_threshold_for_model("unknown-model"), 102_400);
438 }
439
440 #[test]
441 fn compaction_scales_for_deepseek_v4_1m_context() {
442 assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
443 }
444
445 #[test]
446 fn v4_replacement_compaction_ignores_reasoning_effort() {
447 assert_eq!(
448 compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("off")),
449 800_000
450 );
451 assert_eq!(
452 compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("high")),
453 800_000
454 );
455 assert_eq!(
456 compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("max")),
457 800_000
458 );
459 }
460
461 #[test]
462 fn v4_soft_caps_only_apply_to_v4_models() {
463 assert_eq!(
464 compaction_threshold_for_model_and_effort("deepseek-v3.2-128k", Some("max")),
465 102_400
466 );
467 // v0.8.11 (#664): unknown-model fallback also lands on the
468 // 80%-of-128K legacy DeepSeek fallback instead of the legacy
469 // hardcoded 50K, so model-detection-fall-through doesn't quietly
470 // burn V4 prefix cache at 5%-of-window.
471 assert_eq!(
472 compaction_threshold_for_model_and_effort("unknown-model", Some("max")),
473 102_400
474 );
475 }
476
477 #[test]
478 fn v4_replacement_compaction_defaults_to_late_guard_when_effort_unknown() {
479 assert_eq!(
480 compaction_threshold_for_model_and_effort("deepseek-v4-pro", None),
481 800_000
482 );
483 assert_eq!(
484 compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("unknown")),
485 800_000
486 );
487 }
488 }
489
489 lines RUST