返回 CodeWhale
prepared.rs
根目录 / crates / tui / src / client / prepared.rs
1 //! The single prepared-outbound-request seam shared by production dispatch
2 //! and `/preview-request` (#1004, #3928).
3 //!
4 //! Every **primary agent turn** — `LlmClient::create_message` and
5 //! `create_message_stream`, in Chat Completions, Anthropic Messages, and
6 //! OpenAI Responses alike — reaches the wire through
7 //! [`crate::client::DeepSeekClient::prepare_outbound_request`], which returns a
8 //! [`PreparedOutboundRequest`]. The transports send it; the preview command
9 //! describes it. Because there is exactly one builder, a preview cannot
10 //! report a request different from the one a turn would send.
11 //!
12 //! Scope, stated plainly: this is *not* every outbound request Codewhale
13 //! makes. Chat-dialect translation builds its own small fixed body, and FIM,
14 //! speech, provider-native search, model listing, and the auto-router
15 //! classifier are separate calls with separate shapes. They are auxiliary and
16 //! are not described by the request manifest. See `docs/PREVIEW_REQUEST.md`.
17 //!
18 //! Nothing in this module performs I/O, mutates client state, or reads the
19 //! filesystem. It is safe to call on any thread at any time.
20 //!
21 //! The seam concept — prepare the exact outbound body once, then let both the
22 //! sender and the inspector consume it — is harvested from PR #1099
23 //! (`build_sanitized_chat_completion_body`) by TaoMu (GTC2080). The
24 //! implementation here is written against the current multi-dialect client.
25
26 use serde::Serialize;
27 use serde_json::Value;
28
29 use codewhale_config::provider::WireFormat;
30
31 use crate::config::ApiProvider;
32
33 /// The wire protocol a prepared request speaks.
34 ///
35 /// This is the production dialect set. It is deliberately *not* collapsed to
36 /// Chat Completions: projecting an Anthropic Messages or Responses turn
37 /// through the Chat builder would describe a body that is never sent.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39 #[serde(rename_all = "kebab-case")]
40 pub(crate) enum WireDialect {
41 /// OpenAI-style `POST /chat/completions`.
42 ChatCompletions,
43 /// Anthropic-style `POST /v1/messages`.
44 AnthropicMessages,
45 /// OpenAI-style `POST /responses`.
46 OpenAiResponses,
47 }
48
49 impl WireDialect {
50 pub(crate) fn from_wire_format(format: WireFormat) -> Self {
51 match format {
52 WireFormat::ChatCompletions => Self::ChatCompletions,
53 WireFormat::AnthropicMessages => Self::AnthropicMessages,
54 WireFormat::Responses => Self::OpenAiResponses,
55 }
56 }
57
58 /// Stable machine label. Used in manifests and tests.
59 pub(crate) fn as_str(self) -> &'static str {
60 match self {
61 Self::ChatCompletions => "chat-completions",
62 Self::AnthropicMessages => "anthropic-messages",
63 Self::OpenAiResponses => "openai-responses",
64 }
65 }
66 }
67
68 /// The provider-specific *shape* selected inside a dialect.
69 ///
70 /// Two routes can share a dialect and still produce structurally different
71 /// bodies and different endpoint paths (DeepSeek's strict-tools `/beta` path,
72 /// Kimi Code's nested `thinking.effort`, the ChatGPT Codex Responses path).
73 /// Naming the shape keeps the manifest honest about which builder branch ran
74 /// without exposing the route URL.
75 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76 #[serde(rename_all = "kebab-case")]
77 pub(crate) enum RouteShape {
78 /// Plain dialect defaults for this provider.
79 Standard,
80 /// DeepSeek's `/beta/chat/completions` strict-tools path.
81 DeepseekBetaStrictTools,
82 /// The exact Kimi Code membership route (nested `thinking.effort`).
83 KimiCodeK3,
84 /// The exact pay-as-you-go Moonshot K3 route (fixed sampling).
85 DirectMoonshotK3,
86 /// The ChatGPT backend Responses path used by the Codex provider.
87 CodexResponses,
88 /// OpenCode Zen, whose model route re-resolves the wire model per request.
89 OpencodeZen,
90 /// A user-configured custom/compatible endpoint on a standard dialect.
91 CustomCompatible,
92 }
93
94 impl RouteShape {
95 pub(crate) fn as_str(self) -> &'static str {
96 match self {
97 Self::Standard => "standard",
98 Self::DeepseekBetaStrictTools => "deepseek-beta-strict-tools",
99 Self::KimiCodeK3 => "kimi-code-k3",
100 Self::DirectMoonshotK3 => "direct-moonshot-k3",
101 Self::CodexResponses => "codex-responses",
102 Self::OpencodeZen => "opencode-zen",
103 Self::CustomCompatible => "custom-compatible",
104 }
105 }
106 }
107
108 /// Which endpoint this request would be POSTed to, as typed facts.
109 ///
110 /// `url` is the real, unredacted target: production needs it to send. Every
111 /// display surface must go through [`super::redact_url_for_display`] rather
112 /// than printing it, and the manifest only ever publishes the redacted
113 /// scheme/host and a fingerprint — never the path, which can itself carry a
114 /// deployment secret.
115 #[derive(Debug, Clone)]
116 pub(crate) struct EndpointIdentity {
117 /// Stable provider id (`ApiProvider::as_str`).
118 pub(crate) provider_id: String,
119 /// Human-facing provider name.
120 pub(crate) provider_display: String,
121 /// The configured route identity when the user named a custom provider,
122 /// e.g. a `[providers.<name>]` key. `None` for built-ins.
123 pub(crate) route_id: Option<String>,
124 /// Full POST target. Never rendered directly.
125 pub(crate) url: String,
126 /// Which builder branch produced the body.
127 pub(crate) shape: RouteShape,
128 }
129
130 /// What reasoning controls actually landed on the wire, and what was asked
131 /// for.
132 ///
133 /// The receipt is derived from the finished body, not from the intent that
134 /// went in: if a route-specific shaper stripped `reasoning_effort` and wrote
135 /// a nested `thinking.effort` instead, that is what this reports.
136 #[derive(Debug, Clone, PartialEq, Eq)]
137 pub(crate) struct ReasoningReceipt {
138 /// The effort string handed to the builder, if any.
139 pub(crate) requested_effort: Option<String>,
140 /// Reasoning-shaping fields present on the finished body, in a stable
141 /// order. Keys only come from the dialect allowlist below, so no message
142 /// or prompt content can leak through this field.
143 pub(crate) wire_controls: Vec<(String, Value)>,
144 }
145
146 /// A key that only *discloses* reasoning output; it does not ask the route to
147 /// think. Reporting `include` as a reasoning control would make every Responses
148 /// turn look like a deliberate thinking request.
149 const REASONING_DISCLOSURE_ONLY_KEYS: &[&str] = &["include"];
150
151 impl ReasoningReceipt {
152 /// Reasoning-control keys, per dialect. Anything not on this list is not
153 /// a reasoning control and never enters the receipt.
154 fn control_keys(dialect: WireDialect) -> &'static [&'static str] {
155 match dialect {
156 WireDialect::ChatCompletions => &[
157 "reasoning_effort",
158 "thinking",
159 "think",
160 "reasoning",
161 "reasoning_split",
162 "chat_template_kwargs",
163 ],
164 WireDialect::AnthropicMessages => &["thinking", "output_config"],
165 WireDialect::OpenAiResponses => &["reasoning", "include"],
166 }
167 }
168
169 fn from_body(dialect: WireDialect, body: &Value, requested_effort: Option<String>) -> Self {
170 let mut wire_controls = Vec::new();
171 for key in Self::control_keys(dialect) {
172 if let Some(value) = body.get(*key) {
173 wire_controls.push(((*key).to_string(), value.clone()));
174 }
175 }
176 Self {
177 requested_effort,
178 wire_controls,
179 }
180 }
181
182 /// The plain `reasoning_effort` string when the route uses that dialect.
183 pub(crate) fn wire_effort_string(&self) -> Option<&str> {
184 self.wire_controls
185 .iter()
186 .find(|(key, _)| key == "reasoning_effort")
187 .and_then(|(_, value)| value.as_str())
188 }
189
190 /// The effort **actually on the wire**, with the key path it was read from.
191 ///
192 /// Flat `reasoning_effort` is only one of the shapes production emits. The
193 /// Kimi Code route writes `thinking.effort`, the Responses dialect writes
194 /// `reasoning.effort`, and the Anthropic dialect writes
195 /// `output_config.effort`. Reporting only the flat key made every nested
196 /// route read as "no effort sent", which is exactly backwards: those are
197 /// the routes that were asked to think hardest.
198 ///
199 /// The returned key path is a compile-time constant taken from
200 /// [`Self::control_keys`], never a key read out of the body, so no
201 /// provider-shaped field name can reach a manifest surface through it.
202 pub(crate) fn wire_effort(&self) -> Option<(&'static str, &str)> {
203 if let Some(effort) = self.wire_effort_string() {
204 return Some(("reasoning_effort", effort));
205 }
206 for (key, value) in &self.wire_controls {
207 let Some(effort) = value.get("effort").and_then(Value::as_str) else {
208 continue;
209 };
210 let path = match key.as_str() {
211 "thinking" => "thinking.effort",
212 "reasoning" => "reasoning.effort",
213 "output_config" => "output_config.effort",
214 "think" => "think.effort",
215 "reasoning_split" => "reasoning_split.effort",
216 "chat_template_kwargs" => "chat_template_kwargs.effort",
217 _ => continue,
218 };
219 return Some((path, effort));
220 }
221 None
222 }
223
224 /// True when the body actually asks the route to think.
225 ///
226 /// Deliberately *not* "the receipt is non-empty": a Responses body that
227 /// carries only `include: ["reasoning.encrypted_content"]` is *disclosing*
228 /// reasoning output, not requesting a tier, and must not be reported as an
229 /// explicit reasoning selection.
230 pub(crate) fn controls_reasoning(&self) -> bool {
231 self.wire_controls
232 .iter()
233 .any(|(key, _)| !REASONING_DISCLOSURE_ONLY_KEYS.contains(&key.as_str()))
234 }
235 }
236
237 /// Which transport entry point asked for this request.
238 ///
239 /// This is *caller* intent, not a wire fact. The OpenAI Responses blocking
240 /// entry point deliberately opens an SSE stream and folds it into one
241 /// response, so its body carries `"stream": true` while the caller mode is
242 /// [`Self::Blocking`]. Reporting the two separately is the only way for a
243 /// manifest to describe the body exactly and still say which entry point it
244 /// described. See [`PreparedOutboundRequest::wire_stream_field`].
245 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
246 #[serde(rename_all = "kebab-case")]
247 pub(crate) enum CallerStreamMode {
248 /// `create_message_stream` — the caller consumes stream events.
249 Streaming,
250 /// `create_message` — the caller wants one finished response.
251 Blocking,
252 }
253
254 impl CallerStreamMode {
255 pub(crate) fn from_stream_flag(stream: bool) -> Self {
256 if stream {
257 Self::Streaming
258 } else {
259 Self::Blocking
260 }
261 }
262
263 pub(crate) fn as_str(self) -> &'static str {
264 match self {
265 Self::Streaming => "streaming",
266 Self::Blocking => "blocking",
267 }
268 }
269 }
270
271 /// One fully prepared, not-yet-sent outbound request.
272 ///
273 /// Both `DeepSeekClient::create_message*` and `/preview-request` consume this
274 /// value. Adding a field here is how a new wire fact becomes visible to the
275 /// preview; there is no second builder to keep in sync.
276 #[derive(Debug, Clone)]
277 pub(crate) struct PreparedOutboundRequest {
278 pub(crate) dialect: WireDialect,
279 pub(crate) endpoint: EndpointIdentity,
280 /// The model id literally placed on the wire, after route remapping.
281 pub(crate) wire_model: String,
282 /// The final, provider-shaped body. This is the exact JSON that would be
283 /// serialized and POSTed.
284 pub(crate) body: Value,
285 pub(crate) reasoning: ReasoningReceipt,
286 /// Tokens re-sent because thinking-mode replay substituted
287 /// `reasoning_content` (Chat streaming only).
288 pub(crate) replay_input_tokens: Option<u32>,
289 /// Which transport entry point prepared this request. Never a substitute
290 /// for [`Self::wire_stream_field`], which is the wire truth.
291 pub(crate) entrypoint: CallerStreamMode,
292 }
293
294 impl PreparedOutboundRequest {
295 pub(crate) fn new(
296 dialect: WireDialect,
297 endpoint: EndpointIdentity,
298 wire_model: String,
299 body: Value,
300 requested_effort: Option<String>,
301 replay_input_tokens: Option<u32>,
302 entrypoint: CallerStreamMode,
303 ) -> Self {
304 let reasoning = ReasoningReceipt::from_body(dialect, &body, requested_effort);
305 Self {
306 dialect,
307 endpoint,
308 wire_model,
309 body,
310 reasoning,
311 replay_input_tokens,
312 entrypoint,
313 }
314 }
315
316 /// The `stream` field **as it appears on the finished body**, or `None`
317 /// when the body carries no such field at all.
318 ///
319 /// This is the wire truth and the only value a manifest may present as
320 /// "what the request says". It is deliberately not derived from
321 /// [`Self::entrypoint`]: the Responses blocking path sends
322 /// `"stream": true` and the Chat blocking path omits the field entirely,
323 /// so both would be misreported by the caller mode.
324 pub(crate) fn wire_stream_field(&self) -> Option<bool> {
325 self.body.get("stream").and_then(Value::as_bool)
326 }
327
328 /// Canonical serialization of the **complete** final body.
329 ///
330 /// `serde_json` is built with `preserve_order` in this crate, so insertion
331 /// order — not key order — drives `to_string`. Canonicalizing here means
332 /// the hash is stable across builder orderings while still changing when
333 /// any value anywhere in the body changes: max-token fields, tool choice,
334 /// nested reasoning controls, transformed tool schemas, attachment parts,
335 /// stream options, and every message.
336 pub(crate) fn canonical_body(&self) -> String {
337 canonical_json(&self.body)
338 }
339
340 /// SHA-256 over [`Self::canonical_body`]. Whole-body, not a prefix.
341 pub(crate) fn body_sha256(&self) -> String {
342 crate::hashing::sha256_hex(self.canonical_body().as_bytes())
343 }
344
345 /// Dialect-aware view of the finished body, for counting and estimation.
346 pub(crate) fn wire_view(&self) -> WireBodyView<'_> {
347 WireBodyView::extract(self.dialect, &self.body)
348 }
349
350 /// Attach the caller's named route identity (a `[providers.<name>]` key,
351 /// or any other route id the resolved turn plan owns).
352 #[must_use]
353 pub(crate) fn with_route_id(mut self, route_id: Option<String>) -> Self {
354 self.endpoint.route_id = route_id;
355 self
356 }
357
358 /// SHA-256 of the full endpoint URL. Lets two previews be compared for
359 /// "same endpoint?" without either of them printing the path.
360 pub(crate) fn endpoint_fingerprint(&self) -> String {
361 crate::hashing::sha256_hex(self.endpoint.url.as_bytes())
362 }
363
364 /// A bounded endpoint class that never publishes a remote authority.
365 /// Custom-provider tenant subdomains can contain credentials, so every
366 /// non-loopback authority is represented by a short digest.
367 pub(crate) fn safe_endpoint_host_class(&self) -> String {
368 let Ok(url) = reqwest::Url::parse(&self.endpoint.url) else {
369 let digest = crate::hashing::sha256_hex(self.endpoint.url.as_bytes());
370 return format!("unparseable sha256:{}", &digest[..12]);
371 };
372 let scheme = match url.scheme() {
373 "http" => "http",
374 "https" => "https",
375 _ => "other",
376 };
377 let host = url.host_str().unwrap_or_default();
378 let loopback = host.eq_ignore_ascii_case("localhost")
379 || host
380 .parse::<std::net::IpAddr>()
381 .is_ok_and(|address| address.is_loopback());
382 if loopback {
383 return format!("{scheme} loopback");
384 }
385 let authority = url.port().map_or_else(
386 || host.to_ascii_lowercase(),
387 |port| format!("{}:{port}", host.to_ascii_lowercase()),
388 );
389 let digest = crate::hashing::sha256_hex(authority.as_bytes());
390 format!("{scheme} remote sha256:{}", &digest[..12])
391 }
392
393 /// Output cap literally serialized into the primary request body.
394 pub(crate) fn wire_output_cap_tokens(&self) -> Option<u64> {
395 ["max_tokens", "max_completion_tokens", "max_output_tokens"]
396 .into_iter()
397 .find_map(|key| self.body.get(key).and_then(Value::as_u64))
398 }
399 }
400
401 /// Canonical JSON: object keys sorted, no insignificant whitespace.
402 ///
403 /// Deterministic for a given `Value` regardless of how it was built.
404 pub(crate) fn canonical_json(value: &Value) -> String {
405 let mut out = String::new();
406 write_canonical(value, &mut out);
407 out
408 }
409
410 fn write_canonical(value: &Value, out: &mut String) {
411 match value {
412 Value::Object(map) => {
413 let mut keys: Vec<&String> = map.keys().collect();
414 keys.sort_unstable();
415 out.push('{');
416 for (index, key) in keys.iter().enumerate() {
417 if index > 0 {
418 out.push(',');
419 }
420 push_json_string(key, out);
421 out.push(':');
422 write_canonical(&map[*key], out);
423 }
424 out.push('}');
425 }
426 Value::Array(items) => {
427 out.push('[');
428 for (index, item) in items.iter().enumerate() {
429 if index > 0 {
430 out.push(',');
431 }
432 write_canonical(item, out);
433 }
434 out.push(']');
435 }
436 other => out.push_str(&other.to_string()),
437 }
438 }
439
440 fn push_json_string(value: &str, out: &mut String) {
441 out.push_str(&Value::String(value.to_string()).to_string());
442 }
443
444 /// Where a given dialect keeps its system text, turn items, and tool schemas.
445 ///
446 /// Extraction is by shape, never by guessing: a Responses body has
447 /// `instructions`/`input`, an Anthropic body has `system`/`messages`, a Chat
448 /// body carries the system prompt as the first `system`-role message.
449 ///
450 /// # The byte accounting sums exactly
451 ///
452 /// `system_bytes + tool_schema_bytes + item_bytes + framing_bytes ==
453 /// body_bytes`, where `body_bytes` is the length of
454 /// [`PreparedOutboundRequest::canonical_body`] — a stable, key-sorted semantic
455 /// serialization of the body. Production sends the same JSON value, but the
456 /// transport serializer may preserve a different object-key order. These are
457 /// therefore canonical JSON sizes, not literal HTTP payload byte counts. This
458 /// is an accounting decomposition, not a set of four borrowed
459 /// byte ranges in the JSON buffer:
460 ///
461 /// - `system_bytes`, `tool_schema_bytes`, and `item_bytes` are the canonical
462 /// serializations of their *values* (for Chat, `system_bytes` is the
463 /// serialized system-role messages, carved out of the `messages` array);
464 /// - `framing_bytes` is the algebraic remainder after those three canonical
465 /// value-region sizes. It includes every other top-level field and whatever
466 /// JSON structure was not already counted inside a selected array value.
467 ///
468 /// The earlier shape counted selected values and then serialized a *separate*
469 /// object for "framing", which double-omitted key names, brackets, and
470 /// separators and made the parts sum to less than the whole. Framing is now
471 /// defined as the remainder precisely so that cannot happen again;
472 /// [`WireBodyView::partition_is_exact`] asserts only the sum identity; the
473 /// regional names remain attribution estimates over canonical values.
474 ///
475 /// `tool_result_bytes` and `attachment_bytes` are deliberately *not* part of
476 /// the partition: they are subsets of `item_bytes`, reported for attribution.
477 #[derive(Debug, Default)]
478 pub(crate) struct WireBodyView<'a> {
479 /// Canonical byte length of the complete wire body.
480 pub(crate) body_bytes: usize,
481 /// Serialized bytes of the system/instructions region.
482 pub(crate) system_bytes: usize,
483 /// SHA-256 of the canonicalized system/instructions region — the hash of
484 /// the prompt this prepared request would actually send. Empty when the
485 /// request carries no system region.
486 pub(crate) system_sha256: String,
487 /// Serialized bytes of the tool-schema region.
488 pub(crate) tool_schema_bytes: usize,
489 /// SHA-256 of the canonicalized **wire** tool region: the schemas exactly
490 /// as the provider receives them, after every dialect transform and
491 /// strict-mode sanitizer. Empty when the body carries no `tools` field.
492 pub(crate) tool_schema_sha256: String,
493 /// Number of tool schemas on the wire.
494 pub(crate) tool_count: usize,
495 /// Turn items (messages / input items), excluding the system region.
496 pub(crate) items: Vec<&'a Value>,
497 /// Serialized bytes of the turn-item region, including the array's own
498 /// brackets and separators and excluding any carved-out system messages.
499 pub(crate) item_bytes: usize,
500 /// Serialized bytes of tool-result items specifically. Subset of
501 /// [`Self::item_bytes`].
502 pub(crate) tool_result_bytes: usize,
503 /// Number of attachment (image) parts referenced anywhere in the items.
504 pub(crate) attachment_count: usize,
505 /// Serialized bytes of those attachment parts. Subset of
506 /// [`Self::item_bytes`].
507 pub(crate) attachment_bytes: usize,
508 /// Algebraic remainder after the three canonical value-region sizes. This
509 /// includes other top-level fields and JSON structure not already counted
510 /// inside a selected array value.
511 pub(crate) framing_bytes: usize,
512 }
513
514 impl<'a> WireBodyView<'a> {
515 fn extract(dialect: WireDialect, body: &'a Value) -> Self {
516 let body_bytes = canonical_json(body).len();
517 let mut view = Self {
518 body_bytes,
519 ..Self::default()
520 };
521 let Some(object) = body.as_object() else {
522 view.framing_bytes = view.body_bytes;
523 return view;
524 };
525
526 let (system_key, items_key) = match dialect {
527 WireDialect::ChatCompletions => (None, "messages"),
528 WireDialect::AnthropicMessages => (Some("system"), "messages"),
529 WireDialect::OpenAiResponses => (Some("instructions"), "input"),
530 };
531
532 // The system region is accumulated as canonical text so it can be
533 // hashed once, then dropped. The text itself never leaves this scope.
534 let mut system_region = String::new();
535 if let Some(key) = system_key
536 && let Some(system) = object.get(key)
537 {
538 system_region.push_str(&canonical_json(system));
539 }
540
541 if let Some(tools) = object.get("tools") {
542 let canonical_tools = canonical_json(tools);
543 view.tool_schema_bytes = canonical_tools.len();
544 view.tool_schema_sha256 = crate::hashing::sha256_hex(canonical_tools.as_bytes());
545 view.tool_count = tools.as_array().map(Vec::len).unwrap_or(0);
546 }
547
548 if let Some(items_value) = object.get(items_key) {
549 // The whole array, brackets and separators included, so the
550 // accounting can include the canonical array value itself.
551 let mut item_region_bytes = canonical_json(items_value).len();
552 if let Some(items) = items_value.as_array() {
553 for item in items {
554 let bytes = canonical_json(item).len();
555 // Chat Completions carries the system prompt inline as the
556 // first system-role message. Account for it as system, not
557 // as conversation, so cross-dialect numbers stay
558 // comparable — and subtract it from the item region so the
559 // two never double-count the same bytes.
560 if dialect == WireDialect::ChatCompletions
561 && item.get("role").and_then(Value::as_str) == Some("system")
562 {
563 system_region.push_str(&canonical_json(item));
564 item_region_bytes = item_region_bytes.saturating_sub(bytes);
565 continue;
566 }
567 if is_tool_result_item(dialect, item) {
568 view.tool_result_bytes = view.tool_result_bytes.saturating_add(bytes);
569 }
570 let (count, attachment_bytes) = count_attachments(dialect, item);
571 view.attachment_count = view.attachment_count.saturating_add(count);
572 view.attachment_bytes = view.attachment_bytes.saturating_add(attachment_bytes);
573 view.items.push(item);
574 }
575 }
576 view.item_bytes = item_region_bytes;
577 }
578
579 view.system_bytes = system_region.len();
580 if !system_region.is_empty() {
581 view.system_sha256 = crate::hashing::sha256_hex(system_region.as_bytes());
582 }
583
584 // Framing is the algebraic remainder, never a separately serialized
585 // object. The sum is exact; these are not four disjoint byte slices.
586 view.framing_bytes = view
587 .body_bytes
588 .saturating_sub(view.system_bytes)
589 .saturating_sub(view.tool_schema_bytes)
590 .saturating_sub(view.item_bytes);
591 view
592 }
593
594 /// Whether the four partition classes sum to the whole wire body.
595 ///
596 /// The manifest publishes these as exact byte facts, so the invariant is
597 /// asserted in tests across every dialect and both entry points rather
598 /// than merely documented.
599 pub(crate) fn partition_is_exact(&self) -> bool {
600 self.system_bytes
601 .saturating_add(self.tool_schema_bytes)
602 .saturating_add(self.item_bytes)
603 .saturating_add(self.framing_bytes)
604 == self.body_bytes
605 }
606 }
607
608 fn is_tool_result_item(dialect: WireDialect, item: &Value) -> bool {
609 match dialect {
610 WireDialect::ChatCompletions => item.get("role").and_then(Value::as_str) == Some("tool"),
611 WireDialect::AnthropicMessages => item
612 .get("content")
613 .and_then(Value::as_array)
614 .is_some_and(|blocks| {
615 blocks
616 .iter()
617 .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
618 }),
619 WireDialect::OpenAiResponses => {
620 item.get("type").and_then(Value::as_str) == Some("function_call_output")
621 }
622 }
623 }
624
625 /// Count attachment parts and their serialized size. Only sizes leave this
626 /// function — never a URL, path, or payload.
627 fn count_attachments(dialect: WireDialect, item: &Value) -> (usize, usize) {
628 let Some(parts) = item.get("content").and_then(Value::as_array) else {
629 return (0, 0);
630 };
631 let mut count = 0usize;
632 let mut bytes = 0usize;
633 for part in parts {
634 let part_type = part.get("type").and_then(Value::as_str);
635 let is_attachment = match dialect {
636 WireDialect::ChatCompletions => {
637 part_type == Some("image_url") || part.get("image_url").is_some()
638 }
639 WireDialect::AnthropicMessages => {
640 matches!(part_type, Some("image" | "document"))
641 }
642 WireDialect::OpenAiResponses => {
643 matches!(part_type, Some("input_image" | "input_file"))
644 }
645 };
646 if !is_attachment {
647 continue;
648 }
649 count += 1;
650 bytes = bytes.saturating_add(canonical_json(part).len());
651 }
652 (count, bytes)
653 }
654
655 /// Classify the provider-specific shape of a prepared Chat Completions body.
656 pub(crate) fn chat_route_shape(
657 provider: ApiProvider,
658 base_url: &str,
659 wire_model: &str,
660 url: &str,
661 ) -> RouteShape {
662 if provider == ApiProvider::OpencodeZen {
663 return RouteShape::OpencodeZen;
664 }
665 if url.contains("/beta/chat/completions") {
666 return RouteShape::DeepseekBetaStrictTools;
667 }
668 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
669 return RouteShape::KimiCodeK3;
670 }
671 if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
672 return RouteShape::DirectMoonshotK3;
673 }
674 if provider == ApiProvider::Custom {
675 return RouteShape::CustomCompatible;
676 }
677 RouteShape::Standard
678 }
679
680 #[cfg(test)]
681 mod tests {
682 use super::*;
683 use serde_json::{Map, json};
684
685 fn endpoint() -> EndpointIdentity {
686 EndpointIdentity {
687 provider_id: "deepseek".to_string(),
688 provider_display: "DeepSeek".to_string(),
689 route_id: None,
690 url: "https://api.deepseek.com/chat/completions".to_string(),
691 shape: RouteShape::Standard,
692 }
693 }
694
695 fn prepared(body: Value) -> PreparedOutboundRequest {
696 PreparedOutboundRequest::new(
697 WireDialect::ChatCompletions,
698 endpoint(),
699 "deepseek-chat".to_string(),
700 body,
701 Some("high".to_string()),
702 None,
703 CallerStreamMode::Streaming,
704 )
705 }
706
707 #[test]
708 fn canonical_json_is_key_order_independent() {
709 let a = json!({"b": 1, "a": {"z": 2, "y": [3, {"q": 4, "p": 5}]}});
710 let mut b = Map::new();
711 b.insert("a".to_string(), json!({"y": [3, {"p": 5, "q": 4}], "z": 2}));
712 b.insert("b".to_string(), json!(1));
713 assert_eq!(canonical_json(&a), canonical_json(&Value::Object(b)));
714 assert_eq!(
715 canonical_json(&a),
716 r#"{"a":{"y":[3,{"p":5,"q":4}],"z":2},"b":1}"#
717 );
718 }
719
720 #[test]
721 fn body_hash_covers_every_wire_field() {
722 let base = prepared(json!({
723 "model": "deepseek-chat",
724 "messages": [{"role": "user", "content": "hi"}],
725 "max_tokens": 4096,
726 "tools": [{"type": "function", "function": {"name": "read_file"}}],
727 "tool_choice": {"type": "auto"},
728 "reasoning_effort": "high",
729 "stream": true,
730 }));
731 let baseline = base.body_sha256();
732
733 // Every one of these is a field a preview would have to notice.
734 let mutations: Vec<(&str, Value)> = vec![
735 ("max_tokens", json!(2048)),
736 ("tool_choice", json!("required")),
737 ("reasoning_effort", json!("low")),
738 ("stream", json!(false)),
739 ("temperature", json!(0.2)),
740 ];
741 for (key, value) in mutations {
742 let mut body = base.body.clone();
743 body[key] = value;
744 assert_ne!(
745 baseline,
746 prepared(body).body_sha256(),
747 "mutating `{key}` must change the whole-body hash"
748 );
749 }
750
751 // Nested changes: a transformed tool schema and a nested reasoning
752 // control both have to move the hash.
753 let mut nested = base.body.clone();
754 nested["tools"][0]["function"]["parameters"] = json!({"type": "object"});
755 assert_ne!(baseline, prepared(nested).body_sha256());
756
757 let mut thinking = base.body.clone();
758 thinking["thinking"] = json!({"type": "enabled", "effort": "max"});
759 assert_ne!(baseline, prepared(thinking).body_sha256());
760 }
761
762 #[test]
763 fn endpoint_host_class_never_prints_remote_authority_or_path() {
764 let hostile = |url: &str| {
765 let mut endpoint = endpoint();
766 endpoint.url = url.to_string();
767 PreparedOutboundRequest::new(
768 WireDialect::ChatCompletions,
769 endpoint,
770 "model".to_string(),
771 json!({"model": "model", "messages": []}),
772 None,
773 None,
774 CallerStreamMode::Streaming,
775 )
776 };
777
778 let token_host =
779 hostile("https://sk-live-abcdef0123456789.tenant.example/v1/deployments/secret/chat");
780 let same_host_other_path =
781 hostile("https://sk-live-abcdef0123456789.tenant.example/other/private/path");
782 let idn = hostile("https://秘密.example/private/path?api_key=secret");
783
784 let class = token_host.safe_endpoint_host_class();
785 assert_eq!(class, same_host_other_path.safe_endpoint_host_class());
786 assert_ne!(
787 token_host.endpoint_fingerprint(),
788 same_host_other_path.endpoint_fingerprint(),
789 "the separate full-endpoint fingerprint must still detect path drift"
790 );
791 for forbidden in ["sk-live", "tenant", "example", "deployment", "secret"] {
792 assert!(!class.contains(forbidden), "{forbidden} leaked in {class}");
793 }
794 let idn_class = idn.safe_endpoint_host_class();
795 for forbidden in ["秘密", "xn--", "example", "private", "api_key", "secret"] {
796 assert!(
797 !idn_class.contains(forbidden),
798 "{forbidden} leaked in {idn_class}"
799 );
800 }
801 assert!(class.starts_with("https remote sha256:"), "{class}");
802 assert!(class.len() <= 40, "{class}");
803
804 let loopback = hostile("http://127.0.0.1:8080/private/token-shaped-path");
805 assert_eq!(loopback.safe_endpoint_host_class(), "http loopback");
806 }
807
808 #[test]
809 fn wire_output_cap_is_read_only_from_the_finished_body() {
810 assert_eq!(
811 prepared(json!({"max_tokens": 1024})).wire_output_cap_tokens(),
812 Some(1024)
813 );
814 assert_eq!(
815 prepared(json!({"max_completion_tokens": 2048})).wire_output_cap_tokens(),
816 Some(2048)
817 );
818 assert_eq!(
819 prepared(json!({"model": "m"})).wire_output_cap_tokens(),
820 None
821 );
822 }
823
824 #[test]
825 fn reasoning_receipt_reads_the_finished_body_not_the_intent() {
826 // Kimi Code K3 strips `reasoning_effort` and writes nested thinking.
827 let kimi = prepared(json!({
828 "model": "kimi-k3",
829 "messages": [],
830 "thinking": {"type": "enabled", "effort": "max"},
831 }));
832 assert_eq!(kimi.reasoning.requested_effort.as_deref(), Some("high"));
833 assert_eq!(kimi.reasoning.wire_effort_string(), None);
834 assert_eq!(
835 kimi.reasoning.wire_controls,
836 vec![(
837 "thinking".to_string(),
838 json!({"type": "enabled", "effort": "max"})
839 )]
840 );
841 }
842
843 #[test]
844 fn receipt_never_captures_message_or_prompt_fields() {
845 let leaky = prepared(json!({
846 "model": "m",
847 "messages": [{"role": "user", "content": "SECRET PROMPT"}],
848 "instructions": "SECRET INSTRUCTIONS",
849 "reasoning_effort": "high",
850 }));
851 let rendered = format!("{:?}", leaky.reasoning);
852 assert!(!rendered.contains("SECRET PROMPT"), "{rendered}");
853 assert!(!rendered.contains("SECRET INSTRUCTIONS"), "{rendered}");
854 }
855
856 #[test]
857 fn chat_view_folds_the_system_message_into_the_system_region() {
858 let request = prepared(json!({
859 "model": "m",
860 "messages": [
861 {"role": "system", "content": "SYS"},
862 {"role": "user", "content": "hi"},
863 {"role": "tool", "tool_call_id": "c1", "content": "OUT"},
864 ],
865 "tools": [{"type": "function", "function": {"name": "a"}}],
866 "max_tokens": 100,
867 }));
868 let view = request.wire_view();
869 assert!(view.system_bytes > 0);
870 assert_eq!(view.items.len(), 2, "system message is not a turn item");
871 assert!(view.tool_result_bytes > 0);
872 assert_eq!(view.tool_count, 1);
873 assert!(view.framing_bytes > 0);
874 }
875
876 #[test]
877 fn anthropic_and_responses_views_use_their_own_shapes() {
878 let anthropic_request = PreparedOutboundRequest::new(
879 WireDialect::AnthropicMessages,
880 endpoint(),
881 "claude".to_string(),
882 json!({
883 "model": "claude",
884 "system": "SYS",
885 "messages": [
886 {"role": "user", "content": [{"type": "tool_result", "content": "OUT"}]},
887 {"role": "user", "content": [{"type": "image", "source": {"data": "AAA"}}]},
888 ],
889 "tools": [{"name": "a"}, {"name": "b"}],
890 }),
891 None,
892 None,
893 CallerStreamMode::Streaming,
894 );
895 let anthropic = anthropic_request.wire_view();
896 assert!(anthropic.system_bytes > 0);
897 assert_eq!(anthropic.items.len(), 2);
898 assert!(anthropic.tool_result_bytes > 0);
899 assert_eq!(anthropic.attachment_count, 1);
900 assert_eq!(anthropic.tool_count, 2);
901
902 let responses_request = PreparedOutboundRequest::new(
903 WireDialect::OpenAiResponses,
904 endpoint(),
905 "gpt".to_string(),
906 json!({
907 "model": "gpt",
908 "instructions": "SYS",
909 "input": [
910 {"type": "message", "role": "user", "content": [{"type": "input_text"}]},
911 {"type": "function_call_output", "output": "OUT"},
912 ],
913 "tools": [{"name": "a"}],
914 }),
915 None,
916 None,
917 CallerStreamMode::Streaming,
918 );
919 let responses = responses_request.wire_view();
920 assert!(responses.system_bytes > 0);
921 assert_eq!(responses.items.len(), 2);
922 assert!(responses.tool_result_bytes > 0);
923 assert_eq!(responses.tool_count, 1);
924 }
925
926 /// The reviewed defect: nested reasoning shapes were invisible, so every
927 /// route that actually thinks hardest read as "no effort sent".
928 #[test]
929 fn nested_reasoning_efforts_are_read_from_every_dialect() {
930 let kimi = prepared(json!({
931 "model": "kimi-k3",
932 "messages": [],
933 "thinking": {"type": "enabled", "effort": "max"},
934 }));
935 assert_eq!(
936 kimi.reasoning.wire_effort(),
937 Some(("thinking.effort", "max"))
938 );
939 assert!(kimi.reasoning.controls_reasoning());
940
941 let responses = PreparedOutboundRequest::new(
942 WireDialect::OpenAiResponses,
943 endpoint(),
944 "gpt".to_string(),
945 json!({
946 "model": "gpt",
947 "input": [],
948 "reasoning": {"effort": "high", "summary": "auto"},
949 "include": ["reasoning.encrypted_content"],
950 }),
951 None,
952 None,
953 CallerStreamMode::Streaming,
954 );
955 assert_eq!(
956 responses.reasoning.wire_effort(),
957 Some(("reasoning.effort", "high"))
958 );
959 assert!(responses.reasoning.controls_reasoning());
960
961 let anthropic = PreparedOutboundRequest::new(
962 WireDialect::AnthropicMessages,
963 endpoint(),
964 "claude".to_string(),
965 json!({
966 "model": "claude",
967 "messages": [],
968 "output_config": {"effort": "low"},
969 }),
970 None,
971 None,
972 CallerStreamMode::Streaming,
973 );
974 assert_eq!(
975 anthropic.reasoning.wire_effort(),
976 Some(("output_config.effort", "low"))
977 );
978
979 // Flat still wins when the dialect uses it.
980 let chat = prepared(json!({
981 "model": "m",
982 "messages": [],
983 "reasoning_effort": "medium",
984 }));
985 assert_eq!(
986 chat.reasoning.wire_effort(),
987 Some(("reasoning_effort", "medium"))
988 );
989 }
990
991 /// `include` discloses reasoning output; it does not request a tier. A
992 /// body carrying only `include` must not read as a reasoning control.
993 #[test]
994 fn responses_include_alone_is_not_a_reasoning_control() {
995 let disclosure_only = PreparedOutboundRequest::new(
996 WireDialect::OpenAiResponses,
997 endpoint(),
998 "gpt".to_string(),
999 json!({
1000 "model": "gpt",
1001 "input": [],
1002 "include": ["reasoning.encrypted_content"],
1003 }),
1004 None,
1005 None,
1006 CallerStreamMode::Streaming,
1007 );
1008 assert!(
1009 !disclosure_only.reasoning.wire_controls.is_empty(),
1010 "`include` is still disclosed on the receipt"
1011 );
1012 assert!(
1013 !disclosure_only.reasoning.controls_reasoning(),
1014 "`include` alone must not read as a reasoning request"
1015 );
1016 assert_eq!(disclosure_only.reasoning.wire_effort(), None);
1017 }
1018
1019 fn assert_partition_exact(request: &PreparedOutboundRequest, what: &str) {
1020 let view = request.wire_view();
1021 assert_eq!(
1022 view.body_bytes,
1023 request.canonical_body().len(),
1024 "{what}: the view must measure the bytes that would be POSTed"
1025 );
1026 assert!(
1027 view.partition_is_exact(),
1028 "{what}: {} + {} + {} + {} != {}",
1029 view.system_bytes,
1030 view.tool_schema_bytes,
1031 view.item_bytes,
1032 view.framing_bytes,
1033 view.body_bytes
1034 );
1035 assert!(view.tool_result_bytes <= view.item_bytes, "{what}");
1036 assert!(view.attachment_bytes <= view.item_bytes, "{what}");
1037 }
1038
1039 /// The byte classes are published as exact facts, so they must account for
1040 /// every byte of the wire body — key names, brackets, and separators
1041 /// included — in every dialect and on both entry points.
1042 #[test]
1043 fn byte_classes_sum_to_the_whole_wire_body_in_every_dialect() {
1044 assert_partition_exact(
1045 &prepared(json!({
1046 "model": "m",
1047 "messages": [
1048 {"role": "system", "content": "SYS"},
1049 {"role": "user", "content": "hi"},
1050 {"role": "tool", "tool_call_id": "c1", "content": "OUT"},
1051 ],
1052 "tools": [{"type": "function", "function": {"name": "a"}}],
1053 "tool_choice": {"type": "auto"},
1054 "max_tokens": 100,
1055 "stream": true,
1056 })),
1057 "chat streaming",
1058 );
1059 assert_partition_exact(
1060 &prepared(json!({
1061 "model": "m",
1062 "messages": [{"role": "user", "content": "hi"}],
1063 "max_tokens": 100,
1064 })),
1065 "chat blocking (no tools, no system, no stream field)",
1066 );
1067 assert_partition_exact(
1068 &prepared(json!({"model": "m", "messages": []})),
1069 "chat minimal",
1070 );
1071 assert_partition_exact(
1072 &PreparedOutboundRequest::new(
1073 WireDialect::AnthropicMessages,
1074 endpoint(),
1075 "claude".to_string(),
1076 json!({
1077 "model": "claude",
1078 "system": [{"type": "text", "text": "SYS"}],
1079 "messages": [
1080 {"role": "user", "content": [{"type": "tool_result", "content": "OUT"}]},
1081 {"role": "user", "content": [{"type": "image", "source": {"data": "AAA"}}]},
1082 ],
1083 "tools": [{"name": "a"}],
1084 "stream": true,
1085 }),
1086 None,
1087 None,
1088 CallerStreamMode::Streaming,
1089 ),
1090 "anthropic streaming",
1091 );
1092 assert_partition_exact(
1093 &PreparedOutboundRequest::new(
1094 WireDialect::OpenAiResponses,
1095 endpoint(),
1096 "gpt".to_string(),
1097 json!({
1098 "model": "gpt",
1099 "instructions": "SYS",
1100 "input": [
1101 {"type": "message", "role": "user", "content": [{"type": "input_text"}]},
1102 {"type": "function_call_output", "output": "OUT"},
1103 ],
1104 "tools": [{"name": "a"}],
1105 "reasoning": {"effort": "high"},
1106 "stream": true,
1107 }),
1108 None,
1109 None,
1110 CallerStreamMode::Blocking,
1111 ),
1112 "responses blocking entry point (wire still streams)",
1113 );
1114 }
1115
1116 /// Mutating any region must keep the partition exact *and* move the class
1117 /// the mutation belongs to. A partition that stayed exact by dumping the
1118 /// difference into framing would be arithmetically true and useless.
1119 #[test]
1120 fn byte_classes_track_the_region_that_changed() {
1121 let base = json!({
1122 "model": "m",
1123 "messages": [
1124 {"role": "system", "content": "SYS"},
1125 {"role": "user", "content": "hi"},
1126 ],
1127 "tools": [{"type": "function", "function": {"name": "a"}}],
1128 "max_tokens": 100,
1129 });
1130 let baseline = prepared(base.clone());
1131 let baseline_view = baseline.wire_view();
1132
1133 let mut bigger_system = base.clone();
1134 bigger_system["messages"][0]["content"] = json!("SYSTEM PROMPT, MUCH LONGER");
1135 let request = prepared(bigger_system);
1136 let view = request.wire_view();
1137 assert_partition_exact(&request, "grown system");
1138 assert!(view.system_bytes > baseline_view.system_bytes);
1139 assert_eq!(view.item_bytes, baseline_view.item_bytes);
1140
1141 let mut bigger_tools = base.clone();
1142 bigger_tools["tools"][0]["function"]["parameters"] = json!({"type": "object"});
1143 let request = prepared(bigger_tools);
1144 let view = request.wire_view();
1145 assert_partition_exact(&request, "grown tool schema");
1146 assert!(view.tool_schema_bytes > baseline_view.tool_schema_bytes);
1147 assert_ne!(view.tool_schema_sha256, baseline_view.tool_schema_sha256);
1148
1149 let mut extra_message = base.clone();
1150 extra_message["messages"]
1151 .as_array_mut()
1152 .expect("messages array")
1153 .push(json!({"role": "user", "content": "the hypothetical next prompt"}));
1154 let request = prepared(extra_message);
1155 let view = request.wire_view();
1156 assert_partition_exact(&request, "appended message");
1157 assert!(view.item_bytes > baseline_view.item_bytes);
1158 assert_eq!(view.system_bytes, baseline_view.system_bytes);
1159
1160 let mut extra_framing = base;
1161 extra_framing["stream_options"] = json!({"include_usage": true});
1162 let request = prepared(extra_framing);
1163 let view = request.wire_view();
1164 assert_partition_exact(&request, "added framing field");
1165 assert!(view.framing_bytes > baseline_view.framing_bytes);
1166 assert_eq!(view.item_bytes, baseline_view.item_bytes);
1167 }
1168
1169 /// The prefix digest is derived from this hash, so a provider-side schema
1170 /// transform that leaves the logical catalog untouched must still move it.
1171 #[test]
1172 fn wire_tool_hash_tracks_dialect_schema_shaping() {
1173 let logical = prepared(json!({
1174 "model": "m",
1175 "messages": [],
1176 "tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
1177 }));
1178 let shaped = prepared(json!({
1179 "model": "m",
1180 "messages": [],
1181 "tools": [{"type": "function", "function": {
1182 "name": "a",
1183 "parameters": {"type": "object", "additionalProperties": false},
1184 "strict": true,
1185 }}],
1186 }));
1187 assert_ne!(
1188 logical.wire_view().tool_schema_sha256,
1189 shaped.wire_view().tool_schema_sha256,
1190 "strict-mode schema sanitizing must move the wire tool hash"
1191 );
1192
1193 let toolless = prepared(json!({"model": "m", "messages": []}));
1194 assert!(toolless.wire_view().tool_schema_sha256.is_empty());
1195 }
1196
1197 #[test]
1198 fn dialect_labels_are_stable() {
1199 assert_eq!(
1200 WireDialect::from_wire_format(WireFormat::ChatCompletions).as_str(),
1201 "chat-completions"
1202 );
1203 assert_eq!(
1204 WireDialect::from_wire_format(WireFormat::AnthropicMessages).as_str(),
1205 "anthropic-messages"
1206 );
1207 assert_eq!(
1208 WireDialect::from_wire_format(WireFormat::Responses).as_str(),
1209 "openai-responses"
1210 );
1211 }
1212 }
1213
1214 /// Per-dialect proof that `/preview-request` and production dispatch consume
1215 /// the same bytes.
1216 ///
1217 /// Each case builds a real client for a production route, prepares a request
1218 /// through [`DeepSeekClient::prepare_outbound_request`] — the value the
1219 /// transports send and the preview describes — and compares its whole-body
1220 /// hash against the dialect's own builder run over the identically
1221 /// pre-processed request. A divergence here means a second body builder has
1222 /// reappeared.
1223 #[cfg(test)]
1224 mod dialect_seam_tests {
1225 use super::*;
1226 use crate::config::{Config, ProviderConfig, ProvidersConfig};
1227 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool};
1228 use serde_json::json;
1229
1230 use super::super::DeepSeekClient;
1231
1232 fn tool(name: &str) -> Tool {
1233 Tool {
1234 tool_type: None,
1235 name: name.to_string(),
1236 description: format!("{name} description"),
1237 input_schema: json!({"type": "object", "properties": {}}),
1238 allowed_callers: None,
1239 defer_loading: None,
1240 input_examples: None,
1241 strict: None,
1242 cache_control: None,
1243 }
1244 }
1245
1246 fn request(model: &str) -> MessageRequest {
1247 MessageRequest {
1248 model: model.to_string(),
1249 messages: vec![Message {
1250 role: "user".to_string(),
1251 content: vec![ContentBlock::Text {
1252 text: "hello".to_string(),
1253 cache_control: None,
1254 }],
1255 }],
1256 max_tokens: 4096,
1257 system: Some(SystemPrompt::Text("BASE PROMPT".to_string())),
1258 tools: Some(vec![tool("read_file"), tool("Bash")]),
1259 tool_choice: Some(json!({"type": "auto"})),
1260 metadata: None,
1261 thinking: None,
1262 reasoning_effort: Some("high".to_string()),
1263 stream: Some(true),
1264 temperature: None,
1265 top_p: None,
1266 }
1267 }
1268
1269 fn client(provider: &str, configure: impl FnOnce(&mut ProvidersConfig)) -> DeepSeekClient {
1270 let mut providers = ProvidersConfig::default();
1271 configure(&mut providers);
1272 DeepSeekClient::new(&Config {
1273 provider: Some(provider.to_string()),
1274 providers: Some(providers),
1275 ..Config::default()
1276 })
1277 .expect("client resolves for this route")
1278 }
1279
1280 fn configured(api_key: &str, base_url: Option<&str>, model: &str) -> ProviderConfig {
1281 ProviderConfig {
1282 api_key: Some(api_key.to_string()),
1283 base_url: base_url.map(str::to_string),
1284 model: Some(model.to_string()),
1285 ..ProviderConfig::default()
1286 }
1287 }
1288
1289 fn sha256(value: &str) -> String {
1290 crate::hashing::sha256_hex(value.as_bytes())
1291 }
1292
1293 /// The exact pre-processing `prepare_outbound_request` applies before the
1294 /// dialect builder runs. Reproduced here so the reference body is built
1295 /// from the same input, not from a differently-sanitized one.
1296 fn preprocessed(client: &DeepSeekClient, request: MessageRequest) -> MessageRequest {
1297 client
1298 .bind_request_to_protocol(client.prepare_model_bound_request(request))
1299 .expect("protocol binding succeeds")
1300 }
1301
1302 #[test]
1303 fn chat_completions_preview_matches_the_production_chat_builder() {
1304 let client = client("deepseek", |providers| {
1305 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1306 });
1307 let prepared = client
1308 .prepare_outbound_request(request("deepseek-chat"), true)
1309 .expect("chat request prepares");
1310 assert_eq!(prepared.dialect, WireDialect::ChatCompletions);
1311
1312 let reference = super::super::chat::build_chat_wire_body(
1313 &preprocessed(&client, request("deepseek-chat")),
1314 client.api_provider(),
1315 client.base_url(),
1316 true,
1317 )
1318 .expect("reference body builds");
1319
1320 assert_eq!(
1321 prepared.body_sha256(),
1322 sha256(&canonical_json(&reference.body))
1323 );
1324 assert_eq!(prepared.wire_model, reference.model);
1325 assert!(
1326 prepared.body.get("tool_choice").is_none(),
1327 "DeepSeek thinking requests omit tool_choice on the final wire body"
1328 );
1329 }
1330
1331 #[test]
1332 fn kimi_code_keeps_its_own_shape_and_is_not_projected_through_plain_chat() {
1333 let client = client("moonshot", |providers| {
1334 providers.moonshot = configured(
1335 "sk-test-kimi",
1336 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL),
1337 crate::config::KIMI_CODE_K3_MODEL,
1338 );
1339 });
1340 let prepared = client
1341 .prepare_outbound_request(request(crate::config::KIMI_CODE_K3_MODEL), true)
1342 .expect("kimi code request prepares");
1343
1344 assert_eq!(prepared.dialect, WireDialect::ChatCompletions);
1345 assert_eq!(prepared.endpoint.shape, RouteShape::KimiCodeK3);
1346 // The route-specific shaper replaces flat `reasoning_effort` with the
1347 // nested `thinking.effort` dialect; the receipt must show that.
1348 assert_eq!(prepared.reasoning.wire_effort_string(), None);
1349 assert!(
1350 prepared
1351 .reasoning
1352 .wire_controls
1353 .iter()
1354 .any(|(key, _)| key == "thinking"),
1355 "{:?}",
1356 prepared.reasoning
1357 );
1358
1359 let reference = super::super::chat::build_chat_wire_body(
1360 &preprocessed(&client, request(crate::config::KIMI_CODE_K3_MODEL)),
1361 client.api_provider(),
1362 client.base_url(),
1363 true,
1364 )
1365 .expect("reference body builds");
1366 assert_eq!(
1367 prepared.body_sha256(),
1368 sha256(&canonical_json(&reference.body))
1369 );
1370 }
1371
1372 /// Every production Anthropic Messages route: native Anthropic, the
1373 /// DeepSeek Messages route, and the MiniMax Messages route. Each shapes
1374 /// thinking differently, so each is checked against its own builder run.
1375 #[test]
1376 fn anthropic_messages_preview_matches_the_production_messages_builder() {
1377 type ProviderCase = (
1378 &'static str,
1379 &'static str,
1380 Box<dyn Fn(&mut ProvidersConfig)>,
1381 );
1382
1383 let cases: Vec<ProviderCase> = vec![
1384 (
1385 "anthropic",
1386 "claude-sonnet-4-5",
1387 Box::new(|providers: &mut ProvidersConfig| {
1388 providers.anthropic = configured("sk-ant-test", None, "claude-sonnet-4-5");
1389 }),
1390 ),
1391 (
1392 "deepseek-anthropic",
1393 "deepseek-v4",
1394 Box::new(|providers: &mut ProvidersConfig| {
1395 providers.deepseek_anthropic =
1396 configured("sk-test-deepseek-anthropic", None, "deepseek-v4");
1397 }),
1398 ),
1399 (
1400 "minimax-anthropic",
1401 "MiniMax-M3",
1402 Box::new(|providers: &mut ProvidersConfig| {
1403 providers.minimax_anthropic =
1404 configured("sk-test-minimax-anthropic", None, "MiniMax-M3");
1405 }),
1406 ),
1407 ];
1408
1409 for (provider, model, configure) in cases {
1410 let client = client(provider, |providers| configure(providers));
1411 let prepared = client
1412 .prepare_outbound_request(request(model), true)
1413 .unwrap_or_else(|error| panic!("{provider} request prepares: {error}"));
1414
1415 assert_eq!(
1416 prepared.dialect,
1417 WireDialect::AnthropicMessages,
1418 "{provider} must keep the Messages dialect, not be projected through Chat"
1419 );
1420
1421 let reference =
1422 client.build_anthropic_body(&preprocessed(&client, request(model)), true);
1423 assert_eq!(
1424 prepared.body_sha256(),
1425 sha256(&canonical_json(&reference)),
1426 "{provider} preview body must hash identically to the production builder"
1427 );
1428 assert_eq!(
1429 prepared
1430 .body
1431 .get("tool_choice")
1432 .and_then(|value| value.get("type"))
1433 .and_then(serde_json::Value::as_str),
1434 Some("auto"),
1435 "{provider} tool_choice must come from the final Messages body"
1436 );
1437
1438 // The Messages dialect never carries a flat `reasoning_effort`;
1439 // the receipt must reflect the dialect's own controls.
1440 assert_eq!(prepared.reasoning.wire_effort_string(), None, "{provider}");
1441 assert_eq!(
1442 prepared.reasoning.requested_effort.as_deref(),
1443 Some("high"),
1444 "{provider}"
1445 );
1446 }
1447 }
1448
1449 /// Codex resolves its bearer through OAuth, so the test pins a token the
1450 /// same way the Responses adapter's own tests do.
1451 fn codex_client() -> DeepSeekClient {
1452 let _env_lock = crate::test_support::lock_test_env();
1453 let _codex_token =
1454 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
1455 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1456 client("openai-codex", |providers| {
1457 providers.openai_codex = configured("", None, "gpt-5-codex");
1458 })
1459 }
1460
1461 #[test]
1462 fn responses_preview_matches_the_production_responses_builder() {
1463 let client = codex_client();
1464 let prepared = client
1465 .prepare_outbound_request(request("gpt-5-codex"), true)
1466 .expect("responses request prepares");
1467
1468 assert_eq!(prepared.dialect, WireDialect::OpenAiResponses);
1469 assert_eq!(prepared.endpoint.shape, RouteShape::CodexResponses);
1470
1471 let reference = super::super::responses::build_responses_body(&preprocessed(
1472 &client,
1473 request("gpt-5-codex"),
1474 ));
1475 assert_eq!(prepared.body_sha256(), sha256(&canonical_json(&reference)));
1476 assert_eq!(prepared.body.get("tool_choice"), Some(&json!("auto")));
1477 }
1478
1479 #[test]
1480 fn every_dialect_reports_a_distinct_body_hash_for_the_same_logical_request() {
1481 // Guards against the reviewed failure mode: projecting every route
1482 // through the Chat builder would make these collide.
1483 let chat = client("deepseek", |providers| {
1484 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1485 })
1486 .prepare_outbound_request(request("deepseek-chat"), true)
1487 .expect("chat prepares");
1488 let codex = codex_client();
1489 let responses = codex
1490 .prepare_outbound_request(request("gpt-5-codex"), true)
1491 .expect("responses prepares");
1492
1493 assert_ne!(chat.dialect, responses.dialect);
1494 assert_ne!(chat.body_sha256(), responses.body_sha256());
1495 }
1496
1497 #[test]
1498 fn streaming_and_blocking_bodies_are_distinguished_not_conflated() {
1499 let client = client("deepseek", |providers| {
1500 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1501 });
1502 let streaming = client
1503 .prepare_outbound_request(request("deepseek-chat"), true)
1504 .expect("streaming prepares");
1505 let blocking = client
1506 .prepare_outbound_request(request("deepseek-chat"), false)
1507 .expect("blocking prepares");
1508
1509 assert_eq!(streaming.entrypoint, CallerStreamMode::Streaming);
1510 assert_eq!(blocking.entrypoint, CallerStreamMode::Blocking);
1511 // Chat is the dialect where caller mode and wire fact agree: the
1512 // streaming body sets `stream: true`, the blocking body omits it.
1513 assert_eq!(streaming.wire_stream_field(), Some(true));
1514 assert_eq!(blocking.wire_stream_field(), None);
1515 assert_ne!(streaming.body_sha256(), blocking.body_sha256());
1516 }
1517
1518 /// #1004 review finding: the Responses blocking entry point opens an SSE
1519 /// stream and folds it into one response, so its body says
1520 /// `"stream": true` while the caller mode is blocking. The manifest must
1521 /// read the body, never the caller mode.
1522 #[test]
1523 fn responses_wire_streaming_is_read_from_the_body_not_the_caller_mode() {
1524 let client = codex_client();
1525 let blocking = client
1526 .prepare_outbound_request(request("gpt-5-codex"), false)
1527 .expect("blocking responses prepares");
1528
1529 assert_eq!(blocking.entrypoint, CallerStreamMode::Blocking);
1530 assert_eq!(
1531 blocking.wire_stream_field(),
1532 Some(true),
1533 "the Responses blocking path genuinely sends an SSE body"
1534 );
1535
1536 let streaming = client
1537 .prepare_outbound_request(request("gpt-5-codex"), true)
1538 .expect("streaming responses prepares");
1539 assert_eq!(streaming.wire_stream_field(), Some(true));
1540 assert_eq!(
1541 streaming.body_sha256(),
1542 blocking.body_sha256(),
1543 "the two Responses entry points send the same bytes; only the \
1544 caller mode differs"
1545 );
1546 }
1547
1548 #[test]
1549 fn preparation_is_deterministic_across_repeated_calls() {
1550 let client = client("deepseek", |providers| {
1551 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1552 });
1553 let first = client
1554 .prepare_outbound_request(request("deepseek-chat"), true)
1555 .expect("first prepares");
1556 let second = client
1557 .prepare_outbound_request(request("deepseek-chat"), true)
1558 .expect("second prepares");
1559 assert_eq!(first.body_sha256(), second.body_sha256());
1560 assert_eq!(first.endpoint_fingerprint(), second.endpoint_fingerprint());
1561 }
1562 }
1563
1563 lines RUST