| 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 |