返回 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::CodewhaleClient::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 `CodewhaleClient::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 /// Wire-normalized tool names omitted by a route-specific compatibility
293 /// check. This stopgap receipt is intentionally not a projection layer;
294 /// it only transports a bounded user-visible warning.
295 pub(crate) omitted_tool_names: Vec<String>,
296 }
297
298 impl PreparedOutboundRequest {
299 pub(crate) fn new(
300 dialect: WireDialect,
301 endpoint: EndpointIdentity,
302 wire_model: String,
303 body: Value,
304 requested_effort: Option<String>,
305 replay_input_tokens: Option<u32>,
306 entrypoint: CallerStreamMode,
307 ) -> Self {
308 let reasoning = ReasoningReceipt::from_body(dialect, &body, requested_effort);
309 Self {
310 dialect,
311 endpoint,
312 wire_model,
313 body,
314 reasoning,
315 replay_input_tokens,
316 entrypoint,
317 omitted_tool_names: Vec::new(),
318 }
319 }
320
321 #[must_use]
322 pub(crate) fn with_omitted_tool_names(mut self, names: Vec<String>) -> Self {
323 self.omitted_tool_names = names;
324 self
325 }
326
327 /// The `stream` field **as it appears on the finished body**, or `None`
328 /// when the body carries no such field at all.
329 ///
330 /// This is the wire truth and the only value a manifest may present as
331 /// "what the request says". It is deliberately not derived from
332 /// [`Self::entrypoint`]: the Responses blocking path sends
333 /// `"stream": true` and the Chat blocking path omits the field entirely,
334 /// so both would be misreported by the caller mode.
335 pub(crate) fn wire_stream_field(&self) -> Option<bool> {
336 self.body.get("stream").and_then(Value::as_bool)
337 }
338
339 /// Canonical serialization of the **complete** final body.
340 ///
341 /// `serde_json` is built with `preserve_order` in this crate, so insertion
342 /// order — not key order — drives `to_string`. Canonicalizing here means
343 /// the hash is stable across builder orderings while still changing when
344 /// any value anywhere in the body changes: max-token fields, tool choice,
345 /// nested reasoning controls, transformed tool schemas, attachment parts,
346 /// stream options, and every message.
347 pub(crate) fn canonical_body(&self) -> String {
348 canonical_json(&self.body)
349 }
350
351 /// SHA-256 over [`Self::canonical_body`]. Whole-body, not a prefix.
352 pub(crate) fn body_sha256(&self) -> String {
353 crate::hashing::sha256_hex(self.canonical_body().as_bytes())
354 }
355
356 /// Dialect-aware view of the finished body, for counting and estimation.
357 pub(crate) fn wire_view(&self) -> WireBodyView<'_> {
358 WireBodyView::extract(self.dialect, &self.body)
359 }
360
361 /// Attach the caller's named route identity (a `[providers.<name>]` key,
362 /// or any other route id the resolved turn plan owns).
363 #[must_use]
364 pub(crate) fn with_route_id(mut self, route_id: Option<String>) -> Self {
365 self.endpoint.route_id = route_id;
366 self
367 }
368
369 /// SHA-256 of the full endpoint URL. Lets two previews be compared for
370 /// "same endpoint?" without either of them printing the path.
371 pub(crate) fn endpoint_fingerprint(&self) -> String {
372 crate::hashing::sha256_hex(self.endpoint.url.as_bytes())
373 }
374
375 /// A bounded endpoint class that never publishes a remote authority.
376 /// Custom-provider tenant subdomains can contain credentials, so every
377 /// non-loopback authority is represented by a short digest.
378 pub(crate) fn safe_endpoint_host_class(&self) -> String {
379 let Ok(url) = reqwest::Url::parse(&self.endpoint.url) else {
380 let digest = crate::hashing::sha256_hex(self.endpoint.url.as_bytes());
381 return format!("unparseable sha256:{}", &digest[..12]);
382 };
383 let scheme = match url.scheme() {
384 "http" => "http",
385 "https" => "https",
386 _ => "other",
387 };
388 let host = url.host_str().unwrap_or_default();
389 let loopback = host.eq_ignore_ascii_case("localhost")
390 || host
391 .parse::<std::net::IpAddr>()
392 .is_ok_and(|address| address.is_loopback());
393 if loopback {
394 return format!("{scheme} loopback");
395 }
396 let authority = url.port().map_or_else(
397 || host.to_ascii_lowercase(),
398 |port| format!("{}:{port}", host.to_ascii_lowercase()),
399 );
400 let digest = crate::hashing::sha256_hex(authority.as_bytes());
401 format!("{scheme} remote sha256:{}", &digest[..12])
402 }
403
404 /// Output cap literally serialized into the primary request body.
405 pub(crate) fn wire_output_cap_tokens(&self) -> Option<u64> {
406 ["max_tokens", "max_completion_tokens", "max_output_tokens"]
407 .into_iter()
408 .find_map(|key| self.body.get(key).and_then(Value::as_u64))
409 }
410 }
411
412 /// Canonical JSON: object keys sorted, no insignificant whitespace.
413 ///
414 /// Deterministic for a given `Value` regardless of how it was built.
415 pub(crate) fn canonical_json(value: &Value) -> String {
416 let mut out = String::new();
417 write_canonical(value, &mut out);
418 out
419 }
420
421 fn write_canonical(value: &Value, out: &mut String) {
422 match value {
423 Value::Object(map) => {
424 let mut keys: Vec<&String> = map.keys().collect();
425 keys.sort_unstable();
426 out.push('{');
427 for (index, key) in keys.iter().enumerate() {
428 if index > 0 {
429 out.push(',');
430 }
431 push_json_string(key, out);
432 out.push(':');
433 write_canonical(&map[*key], out);
434 }
435 out.push('}');
436 }
437 Value::Array(items) => {
438 out.push('[');
439 for (index, item) in items.iter().enumerate() {
440 if index > 0 {
441 out.push(',');
442 }
443 write_canonical(item, out);
444 }
445 out.push(']');
446 }
447 other => out.push_str(&other.to_string()),
448 }
449 }
450
451 fn push_json_string(value: &str, out: &mut String) {
452 out.push_str(&Value::String(value.to_string()).to_string());
453 }
454
455 /// Where a given dialect keeps its system text, turn items, and tool schemas.
456 ///
457 /// Extraction is by shape, never by guessing: a Responses body has
458 /// `instructions`/`input`, an Anthropic body has `system`/`messages`, a Chat
459 /// body carries the system prompt as the first `system`-role message.
460 ///
461 /// # The byte accounting sums exactly
462 ///
463 /// `system_bytes + tool_schema_bytes + item_bytes + framing_bytes ==
464 /// body_bytes`, where `body_bytes` is the length of
465 /// [`PreparedOutboundRequest::canonical_body`] — a stable, key-sorted semantic
466 /// serialization of the body. Production sends the same JSON value, but the
467 /// transport serializer may preserve a different object-key order. These are
468 /// therefore canonical JSON sizes, not literal HTTP payload byte counts. This
469 /// is an accounting decomposition, not a set of four borrowed
470 /// byte ranges in the JSON buffer:
471 ///
472 /// - `system_bytes`, `tool_schema_bytes`, and `item_bytes` are the canonical
473 /// serializations of their *values* (for Chat, `system_bytes` is the
474 /// serialized system-role messages, carved out of the `messages` array);
475 /// - `framing_bytes` is the algebraic remainder after those three canonical
476 /// value-region sizes. It includes every other top-level field and whatever
477 /// JSON structure was not already counted inside a selected array value.
478 ///
479 /// The earlier shape counted selected values and then serialized a *separate*
480 /// object for "framing", which double-omitted key names, brackets, and
481 /// separators and made the parts sum to less than the whole. Framing is now
482 /// defined as the remainder precisely so that cannot happen again;
483 /// [`WireBodyView::partition_is_exact`] asserts only the sum identity; the
484 /// regional names remain attribution estimates over canonical values.
485 ///
486 /// `tool_result_bytes` and `attachment_bytes` are deliberately *not* part of
487 /// the partition: they are subsets of `item_bytes`, reported for attribution.
488 #[derive(Debug, Default)]
489 pub(crate) struct WireBodyView<'a> {
490 /// Canonical byte length of the complete wire body.
491 pub(crate) body_bytes: usize,
492 /// Serialized bytes of the system/instructions region.
493 pub(crate) system_bytes: usize,
494 /// SHA-256 of the canonicalized system/instructions region — the hash of
495 /// the prompt this prepared request would actually send. Empty when the
496 /// request carries no system region.
497 pub(crate) system_sha256: String,
498 /// Serialized bytes of the tool-schema region.
499 pub(crate) tool_schema_bytes: usize,
500 /// SHA-256 of the canonicalized **wire** tool region: the schemas exactly
501 /// as the provider receives them, after every dialect transform and
502 /// strict-mode sanitizer. Empty when the body carries no `tools` field.
503 pub(crate) tool_schema_sha256: String,
504 /// Number of tool schemas on the wire.
505 pub(crate) tool_count: usize,
506 /// Turn items (messages / input items), excluding the system region.
507 pub(crate) items: Vec<&'a Value>,
508 /// Serialized bytes of the turn-item region, including the array's own
509 /// brackets and separators and excluding any carved-out system messages.
510 pub(crate) item_bytes: usize,
511 /// Serialized bytes of tool-result items specifically. Subset of
512 /// [`Self::item_bytes`].
513 pub(crate) tool_result_bytes: usize,
514 /// Number of attachment (image) parts referenced anywhere in the items.
515 pub(crate) attachment_count: usize,
516 /// Serialized bytes of those attachment parts. Subset of
517 /// [`Self::item_bytes`].
518 pub(crate) attachment_bytes: usize,
519 /// Algebraic remainder after the three canonical value-region sizes. This
520 /// includes other top-level fields and JSON structure not already counted
521 /// inside a selected array value.
522 pub(crate) framing_bytes: usize,
523 }
524
525 impl<'a> WireBodyView<'a> {
526 fn extract(dialect: WireDialect, body: &'a Value) -> Self {
527 let body_bytes = canonical_json(body).len();
528 let mut view = Self {
529 body_bytes,
530 ..Self::default()
531 };
532 let Some(object) = body.as_object() else {
533 view.framing_bytes = view.body_bytes;
534 return view;
535 };
536
537 let (system_key, items_key) = match dialect {
538 WireDialect::ChatCompletions => (None, "messages"),
539 WireDialect::AnthropicMessages => (Some("system"), "messages"),
540 WireDialect::OpenAiResponses => (Some("instructions"), "input"),
541 };
542
543 // The system region is accumulated as canonical text so it can be
544 // hashed once, then dropped. The text itself never leaves this scope.
545 let mut system_region = String::new();
546 if let Some(key) = system_key
547 && let Some(system) = object.get(key)
548 {
549 system_region.push_str(&canonical_json(system));
550 }
551
552 if let Some(tools) = object.get("tools") {
553 let canonical_tools = canonical_json(tools);
554 view.tool_schema_bytes = canonical_tools.len();
555 view.tool_schema_sha256 = crate::hashing::sha256_hex(canonical_tools.as_bytes());
556 view.tool_count = tools.as_array().map(Vec::len).unwrap_or(0);
557 }
558
559 if let Some(items_value) = object.get(items_key) {
560 // The whole array, brackets and separators included, so the
561 // accounting can include the canonical array value itself.
562 let mut item_region_bytes = canonical_json(items_value).len();
563 if let Some(items) = items_value.as_array() {
564 for item in items {
565 let bytes = canonical_json(item).len();
566 // Chat Completions carries the system prompt inline as the
567 // first system-role message. Account for it as system, not
568 // as conversation, so cross-dialect numbers stay
569 // comparable — and subtract it from the item region so the
570 // two never double-count the same bytes.
571 if dialect == WireDialect::ChatCompletions
572 && item.get("role").and_then(Value::as_str) == Some("system")
573 {
574 system_region.push_str(&canonical_json(item));
575 item_region_bytes = item_region_bytes.saturating_sub(bytes);
576 continue;
577 }
578 if is_tool_result_item(dialect, item) {
579 view.tool_result_bytes = view.tool_result_bytes.saturating_add(bytes);
580 }
581 let (count, attachment_bytes) = count_attachments(dialect, item);
582 view.attachment_count = view.attachment_count.saturating_add(count);
583 view.attachment_bytes = view.attachment_bytes.saturating_add(attachment_bytes);
584 view.items.push(item);
585 }
586 }
587 view.item_bytes = item_region_bytes;
588 }
589
590 view.system_bytes = system_region.len();
591 if !system_region.is_empty() {
592 view.system_sha256 = crate::hashing::sha256_hex(system_region.as_bytes());
593 }
594
595 // Framing is the algebraic remainder, never a separately serialized
596 // object. The sum is exact; these are not four disjoint byte slices.
597 view.framing_bytes = view
598 .body_bytes
599 .saturating_sub(view.system_bytes)
600 .saturating_sub(view.tool_schema_bytes)
601 .saturating_sub(view.item_bytes);
602 view
603 }
604
605 /// Whether the four partition classes sum to the whole wire body.
606 ///
607 /// The manifest publishes these as exact byte facts, so the invariant is
608 /// asserted in tests across every dialect and both entry points rather
609 /// than merely documented.
610 pub(crate) fn partition_is_exact(&self) -> bool {
611 self.system_bytes
612 .saturating_add(self.tool_schema_bytes)
613 .saturating_add(self.item_bytes)
614 .saturating_add(self.framing_bytes)
615 == self.body_bytes
616 }
617 }
618
619 fn is_tool_result_item(dialect: WireDialect, item: &Value) -> bool {
620 match dialect {
621 WireDialect::ChatCompletions => item.get("role").and_then(Value::as_str) == Some("tool"),
622 WireDialect::AnthropicMessages => item
623 .get("content")
624 .and_then(Value::as_array)
625 .is_some_and(|blocks| {
626 blocks
627 .iter()
628 .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
629 }),
630 WireDialect::OpenAiResponses => {
631 item.get("type").and_then(Value::as_str) == Some("function_call_output")
632 }
633 }
634 }
635
636 /// Count attachment parts and their serialized size. Only sizes leave this
637 /// function — never a URL, path, or payload.
638 fn count_attachments(dialect: WireDialect, item: &Value) -> (usize, usize) {
639 let Some(parts) = item.get("content").and_then(Value::as_array) else {
640 return (0, 0);
641 };
642 let mut count = 0usize;
643 let mut bytes = 0usize;
644 for part in parts {
645 let part_type = part.get("type").and_then(Value::as_str);
646 let is_attachment = match dialect {
647 WireDialect::ChatCompletions => {
648 part_type == Some("image_url") || part.get("image_url").is_some()
649 }
650 WireDialect::AnthropicMessages => {
651 matches!(part_type, Some("image" | "document"))
652 }
653 WireDialect::OpenAiResponses => {
654 matches!(part_type, Some("input_image" | "input_file"))
655 }
656 };
657 if !is_attachment {
658 continue;
659 }
660 count += 1;
661 bytes = bytes.saturating_add(canonical_json(part).len());
662 }
663 (count, bytes)
664 }
665
666 /// Classify the provider-specific shape of a prepared Chat Completions body.
667 pub(crate) fn chat_route_shape(
668 provider: ApiProvider,
669 base_url: &str,
670 wire_model: &str,
671 url: &str,
672 ) -> RouteShape {
673 if provider == ApiProvider::OpencodeZen {
674 return RouteShape::OpencodeZen;
675 }
676 if url.contains("/beta/chat/completions") {
677 return RouteShape::DeepseekBetaStrictTools;
678 }
679 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
680 return RouteShape::KimiCodeK3;
681 }
682 if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
683 return RouteShape::DirectMoonshotK3;
684 }
685 if provider == ApiProvider::Custom {
686 return RouteShape::CustomCompatible;
687 }
688 RouteShape::Standard
689 }
690
691 #[cfg(test)]
692 mod tests {
693 use super::*;
694 use serde_json::{Map, json};
695
696 fn endpoint() -> EndpointIdentity {
697 EndpointIdentity {
698 provider_id: "deepseek".to_string(),
699 provider_display: "DeepSeek".to_string(),
700 route_id: None,
701 url: "https://api.deepseek.com/chat/completions".to_string(),
702 shape: RouteShape::Standard,
703 }
704 }
705
706 fn prepared(body: Value) -> PreparedOutboundRequest {
707 PreparedOutboundRequest::new(
708 WireDialect::ChatCompletions,
709 endpoint(),
710 "deepseek-chat".to_string(),
711 body,
712 Some("high".to_string()),
713 None,
714 CallerStreamMode::Streaming,
715 )
716 }
717
718 #[test]
719 fn canonical_json_is_key_order_independent() {
720 let a = json!({"b": 1, "a": {"z": 2, "y": [3, {"q": 4, "p": 5}]}});
721 let mut b = Map::new();
722 b.insert("a".to_string(), json!({"y": [3, {"p": 5, "q": 4}], "z": 2}));
723 b.insert("b".to_string(), json!(1));
724 assert_eq!(canonical_json(&a), canonical_json(&Value::Object(b)));
725 assert_eq!(
726 canonical_json(&a),
727 r#"{"a":{"y":[3,{"p":5,"q":4}],"z":2},"b":1}"#
728 );
729 }
730
731 #[test]
732 fn body_hash_covers_every_wire_field() {
733 let base = prepared(json!({
734 "model": "deepseek-chat",
735 "messages": [{"role": "user", "content": "hi"}],
736 "max_tokens": 4096,
737 "tools": [{"type": "function", "function": {"name": "read_file"}}],
738 "tool_choice": {"type": "auto"},
739 "reasoning_effort": "high",
740 "stream": true,
741 }));
742 let baseline = base.body_sha256();
743
744 // Every one of these is a field a preview would have to notice.
745 let mutations: Vec<(&str, Value)> = vec![
746 ("max_tokens", json!(2048)),
747 ("tool_choice", json!("required")),
748 ("reasoning_effort", json!("low")),
749 ("stream", json!(false)),
750 ("temperature", json!(0.2)),
751 ];
752 for (key, value) in mutations {
753 let mut body = base.body.clone();
754 body[key] = value;
755 assert_ne!(
756 baseline,
757 prepared(body).body_sha256(),
758 "mutating `{key}` must change the whole-body hash"
759 );
760 }
761
762 // Nested changes: a transformed tool schema and a nested reasoning
763 // control both have to move the hash.
764 let mut nested = base.body.clone();
765 nested["tools"][0]["function"]["parameters"] = json!({"type": "object"});
766 assert_ne!(baseline, prepared(nested).body_sha256());
767
768 let mut thinking = base.body.clone();
769 thinking["thinking"] = json!({"type": "enabled", "effort": "max"});
770 assert_ne!(baseline, prepared(thinking).body_sha256());
771 }
772
773 #[test]
774 fn endpoint_host_class_never_prints_remote_authority_or_path() {
775 let hostile = |url: &str| {
776 let mut endpoint = endpoint();
777 endpoint.url = url.to_string();
778 PreparedOutboundRequest::new(
779 WireDialect::ChatCompletions,
780 endpoint,
781 "model".to_string(),
782 json!({"model": "model", "messages": []}),
783 None,
784 None,
785 CallerStreamMode::Streaming,
786 )
787 };
788
789 let token_host =
790 hostile("https://sk-live-abcdef0123456789.tenant.example/v1/deployments/secret/chat");
791 let same_host_other_path =
792 hostile("https://sk-live-abcdef0123456789.tenant.example/other/private/path");
793 let idn = hostile("https://秘密.example/private/path?api_key=secret");
794
795 let class = token_host.safe_endpoint_host_class();
796 assert_eq!(class, same_host_other_path.safe_endpoint_host_class());
797 assert_ne!(
798 token_host.endpoint_fingerprint(),
799 same_host_other_path.endpoint_fingerprint(),
800 "the separate full-endpoint fingerprint must still detect path drift"
801 );
802 for forbidden in ["sk-live", "tenant", "example", "deployment", "secret"] {
803 assert!(!class.contains(forbidden), "{forbidden} leaked in {class}");
804 }
805 let idn_class = idn.safe_endpoint_host_class();
806 for forbidden in ["秘密", "xn--", "example", "private", "api_key", "secret"] {
807 assert!(
808 !idn_class.contains(forbidden),
809 "{forbidden} leaked in {idn_class}"
810 );
811 }
812 assert!(class.starts_with("https remote sha256:"), "{class}");
813 assert!(class.len() <= 40, "{class}");
814
815 let loopback = hostile("http://127.0.0.1:8080/private/token-shaped-path");
816 assert_eq!(loopback.safe_endpoint_host_class(), "http loopback");
817 }
818
819 #[test]
820 fn wire_output_cap_is_read_only_from_the_finished_body() {
821 assert_eq!(
822 prepared(json!({"max_tokens": 1024})).wire_output_cap_tokens(),
823 Some(1024)
824 );
825 assert_eq!(
826 prepared(json!({"max_completion_tokens": 2048})).wire_output_cap_tokens(),
827 Some(2048)
828 );
829 assert_eq!(
830 prepared(json!({"model": "m"})).wire_output_cap_tokens(),
831 None
832 );
833 }
834
835 #[test]
836 fn reasoning_receipt_reads_the_finished_body_not_the_intent() {
837 // Kimi Code K3 strips `reasoning_effort` and writes nested thinking.
838 let kimi = prepared(json!({
839 "model": "kimi-k3",
840 "messages": [],
841 "thinking": {"type": "enabled", "effort": "max"},
842 }));
843 assert_eq!(kimi.reasoning.requested_effort.as_deref(), Some("high"));
844 assert_eq!(kimi.reasoning.wire_effort_string(), None);
845 assert_eq!(
846 kimi.reasoning.wire_controls,
847 vec![(
848 "thinking".to_string(),
849 json!({"type": "enabled", "effort": "max"})
850 )]
851 );
852 }
853
854 #[test]
855 fn receipt_never_captures_message_or_prompt_fields() {
856 let leaky = prepared(json!({
857 "model": "m",
858 "messages": [{"role": "user", "content": "SECRET PROMPT"}],
859 "instructions": "SECRET INSTRUCTIONS",
860 "reasoning_effort": "high",
861 }));
862 let rendered = format!("{:?}", leaky.reasoning);
863 assert!(!rendered.contains("SECRET PROMPT"), "{rendered}");
864 assert!(!rendered.contains("SECRET INSTRUCTIONS"), "{rendered}");
865 }
866
867 #[test]
868 fn chat_view_folds_the_system_message_into_the_system_region() {
869 let request = prepared(json!({
870 "model": "m",
871 "messages": [
872 {"role": "system", "content": "SYS"},
873 {"role": "user", "content": "hi"},
874 {"role": "tool", "tool_call_id": "c1", "content": "OUT"},
875 ],
876 "tools": [{"type": "function", "function": {"name": "a"}}],
877 "max_tokens": 100,
878 }));
879 let view = request.wire_view();
880 assert!(view.system_bytes > 0);
881 assert_eq!(view.items.len(), 2, "system message is not a turn item");
882 assert!(view.tool_result_bytes > 0);
883 assert_eq!(view.tool_count, 1);
884 assert!(view.framing_bytes > 0);
885 }
886
887 #[test]
888 fn anthropic_and_responses_views_use_their_own_shapes() {
889 let anthropic_request = PreparedOutboundRequest::new(
890 WireDialect::AnthropicMessages,
891 endpoint(),
892 "claude".to_string(),
893 json!({
894 "model": "claude",
895 "system": "SYS",
896 "messages": [
897 {"role": "user", "content": [{"type": "tool_result", "content": "OUT"}]},
898 {"role": "user", "content": [{"type": "image", "source": {"data": "AAA"}}]},
899 ],
900 "tools": [{"name": "a"}, {"name": "b"}],
901 }),
902 None,
903 None,
904 CallerStreamMode::Streaming,
905 );
906 let anthropic = anthropic_request.wire_view();
907 assert!(anthropic.system_bytes > 0);
908 assert_eq!(anthropic.items.len(), 2);
909 assert!(anthropic.tool_result_bytes > 0);
910 assert_eq!(anthropic.attachment_count, 1);
911 assert_eq!(anthropic.tool_count, 2);
912
913 let responses_request = PreparedOutboundRequest::new(
914 WireDialect::OpenAiResponses,
915 endpoint(),
916 "gpt".to_string(),
917 json!({
918 "model": "gpt",
919 "instructions": "SYS",
920 "input": [
921 {"type": "message", "role": "user", "content": [{"type": "input_text"}]},
922 {"type": "function_call_output", "output": "OUT"},
923 ],
924 "tools": [{"name": "a"}],
925 }),
926 None,
927 None,
928 CallerStreamMode::Streaming,
929 );
930 let responses = responses_request.wire_view();
931 assert!(responses.system_bytes > 0);
932 assert_eq!(responses.items.len(), 2);
933 assert!(responses.tool_result_bytes > 0);
934 assert_eq!(responses.tool_count, 1);
935 }
936
937 /// The reviewed defect: nested reasoning shapes were invisible, so every
938 /// route that actually thinks hardest read as "no effort sent".
939 #[test]
940 fn nested_reasoning_efforts_are_read_from_every_dialect() {
941 let kimi = prepared(json!({
942 "model": "kimi-k3",
943 "messages": [],
944 "thinking": {"type": "enabled", "effort": "max"},
945 }));
946 assert_eq!(
947 kimi.reasoning.wire_effort(),
948 Some(("thinking.effort", "max"))
949 );
950 assert!(kimi.reasoning.controls_reasoning());
951
952 let responses = PreparedOutboundRequest::new(
953 WireDialect::OpenAiResponses,
954 endpoint(),
955 "gpt".to_string(),
956 json!({
957 "model": "gpt",
958 "input": [],
959 "reasoning": {"effort": "high", "summary": "auto"},
960 "include": ["reasoning.encrypted_content"],
961 }),
962 None,
963 None,
964 CallerStreamMode::Streaming,
965 );
966 assert_eq!(
967 responses.reasoning.wire_effort(),
968 Some(("reasoning.effort", "high"))
969 );
970 assert!(responses.reasoning.controls_reasoning());
971
972 let anthropic = PreparedOutboundRequest::new(
973 WireDialect::AnthropicMessages,
974 endpoint(),
975 "claude".to_string(),
976 json!({
977 "model": "claude",
978 "messages": [],
979 "output_config": {"effort": "low"},
980 }),
981 None,
982 None,
983 CallerStreamMode::Streaming,
984 );
985 assert_eq!(
986 anthropic.reasoning.wire_effort(),
987 Some(("output_config.effort", "low"))
988 );
989
990 // Flat still wins when the dialect uses it.
991 let chat = prepared(json!({
992 "model": "m",
993 "messages": [],
994 "reasoning_effort": "medium",
995 }));
996 assert_eq!(
997 chat.reasoning.wire_effort(),
998 Some(("reasoning_effort", "medium"))
999 );
1000 }
1001
1002 /// `include` discloses reasoning output; it does not request a tier. A
1003 /// body carrying only `include` must not read as a reasoning control.
1004 #[test]
1005 fn responses_include_alone_is_not_a_reasoning_control() {
1006 let disclosure_only = PreparedOutboundRequest::new(
1007 WireDialect::OpenAiResponses,
1008 endpoint(),
1009 "gpt".to_string(),
1010 json!({
1011 "model": "gpt",
1012 "input": [],
1013 "include": ["reasoning.encrypted_content"],
1014 }),
1015 None,
1016 None,
1017 CallerStreamMode::Streaming,
1018 );
1019 assert!(
1020 !disclosure_only.reasoning.wire_controls.is_empty(),
1021 "`include` is still disclosed on the receipt"
1022 );
1023 assert!(
1024 !disclosure_only.reasoning.controls_reasoning(),
1025 "`include` alone must not read as a reasoning request"
1026 );
1027 assert_eq!(disclosure_only.reasoning.wire_effort(), None);
1028 }
1029
1030 fn assert_partition_exact(request: &PreparedOutboundRequest, what: &str) {
1031 let view = request.wire_view();
1032 assert_eq!(
1033 view.body_bytes,
1034 request.canonical_body().len(),
1035 "{what}: the view must measure the bytes that would be POSTed"
1036 );
1037 assert!(
1038 view.partition_is_exact(),
1039 "{what}: {} + {} + {} + {} != {}",
1040 view.system_bytes,
1041 view.tool_schema_bytes,
1042 view.item_bytes,
1043 view.framing_bytes,
1044 view.body_bytes
1045 );
1046 assert!(view.tool_result_bytes <= view.item_bytes, "{what}");
1047 assert!(view.attachment_bytes <= view.item_bytes, "{what}");
1048 }
1049
1050 /// The byte classes are published as exact facts, so they must account for
1051 /// every byte of the wire body — key names, brackets, and separators
1052 /// included — in every dialect and on both entry points.
1053 #[test]
1054 fn byte_classes_sum_to_the_whole_wire_body_in_every_dialect() {
1055 assert_partition_exact(
1056 &prepared(json!({
1057 "model": "m",
1058 "messages": [
1059 {"role": "system", "content": "SYS"},
1060 {"role": "user", "content": "hi"},
1061 {"role": "tool", "tool_call_id": "c1", "content": "OUT"},
1062 ],
1063 "tools": [{"type": "function", "function": {"name": "a"}}],
1064 "tool_choice": {"type": "auto"},
1065 "max_tokens": 100,
1066 "stream": true,
1067 })),
1068 "chat streaming",
1069 );
1070 assert_partition_exact(
1071 &prepared(json!({
1072 "model": "m",
1073 "messages": [{"role": "user", "content": "hi"}],
1074 "max_tokens": 100,
1075 })),
1076 "chat blocking (no tools, no system, no stream field)",
1077 );
1078 assert_partition_exact(
1079 &prepared(json!({"model": "m", "messages": []})),
1080 "chat minimal",
1081 );
1082 assert_partition_exact(
1083 &PreparedOutboundRequest::new(
1084 WireDialect::AnthropicMessages,
1085 endpoint(),
1086 "claude".to_string(),
1087 json!({
1088 "model": "claude",
1089 "system": [{"type": "text", "text": "SYS"}],
1090 "messages": [
1091 {"role": "user", "content": [{"type": "tool_result", "content": "OUT"}]},
1092 {"role": "user", "content": [{"type": "image", "source": {"data": "AAA"}}]},
1093 ],
1094 "tools": [{"name": "a"}],
1095 "stream": true,
1096 }),
1097 None,
1098 None,
1099 CallerStreamMode::Streaming,
1100 ),
1101 "anthropic streaming",
1102 );
1103 assert_partition_exact(
1104 &PreparedOutboundRequest::new(
1105 WireDialect::OpenAiResponses,
1106 endpoint(),
1107 "gpt".to_string(),
1108 json!({
1109 "model": "gpt",
1110 "instructions": "SYS",
1111 "input": [
1112 {"type": "message", "role": "user", "content": [{"type": "input_text"}]},
1113 {"type": "function_call_output", "output": "OUT"},
1114 ],
1115 "tools": [{"name": "a"}],
1116 "reasoning": {"effort": "high"},
1117 "stream": true,
1118 }),
1119 None,
1120 None,
1121 CallerStreamMode::Blocking,
1122 ),
1123 "responses blocking entry point (wire still streams)",
1124 );
1125 }
1126
1127 /// Mutating any region must keep the partition exact *and* move the class
1128 /// the mutation belongs to. A partition that stayed exact by dumping the
1129 /// difference into framing would be arithmetically true and useless.
1130 #[test]
1131 fn byte_classes_track_the_region_that_changed() {
1132 let base = json!({
1133 "model": "m",
1134 "messages": [
1135 {"role": "system", "content": "SYS"},
1136 {"role": "user", "content": "hi"},
1137 ],
1138 "tools": [{"type": "function", "function": {"name": "a"}}],
1139 "max_tokens": 100,
1140 });
1141 let baseline = prepared(base.clone());
1142 let baseline_view = baseline.wire_view();
1143
1144 let mut bigger_system = base.clone();
1145 bigger_system["messages"][0]["content"] = json!("SYSTEM PROMPT, MUCH LONGER");
1146 let request = prepared(bigger_system);
1147 let view = request.wire_view();
1148 assert_partition_exact(&request, "grown system");
1149 assert!(view.system_bytes > baseline_view.system_bytes);
1150 assert_eq!(view.item_bytes, baseline_view.item_bytes);
1151
1152 let mut bigger_tools = base.clone();
1153 bigger_tools["tools"][0]["function"]["parameters"] = json!({"type": "object"});
1154 let request = prepared(bigger_tools);
1155 let view = request.wire_view();
1156 assert_partition_exact(&request, "grown tool schema");
1157 assert!(view.tool_schema_bytes > baseline_view.tool_schema_bytes);
1158 assert_ne!(view.tool_schema_sha256, baseline_view.tool_schema_sha256);
1159
1160 let mut extra_message = base.clone();
1161 extra_message["messages"]
1162 .as_array_mut()
1163 .expect("messages array")
1164 .push(json!({"role": "user", "content": "the hypothetical next prompt"}));
1165 let request = prepared(extra_message);
1166 let view = request.wire_view();
1167 assert_partition_exact(&request, "appended message");
1168 assert!(view.item_bytes > baseline_view.item_bytes);
1169 assert_eq!(view.system_bytes, baseline_view.system_bytes);
1170
1171 let mut extra_framing = base;
1172 extra_framing["stream_options"] = json!({"include_usage": true});
1173 let request = prepared(extra_framing);
1174 let view = request.wire_view();
1175 assert_partition_exact(&request, "added framing field");
1176 assert!(view.framing_bytes > baseline_view.framing_bytes);
1177 assert_eq!(view.item_bytes, baseline_view.item_bytes);
1178 }
1179
1180 /// The prefix digest is derived from this hash, so a provider-side schema
1181 /// transform that leaves the logical catalog untouched must still move it.
1182 #[test]
1183 fn wire_tool_hash_tracks_dialect_schema_shaping() {
1184 let logical = prepared(json!({
1185 "model": "m",
1186 "messages": [],
1187 "tools": [{"type": "function", "function": {"name": "a", "parameters": {"type": "object"}}}],
1188 }));
1189 let shaped = prepared(json!({
1190 "model": "m",
1191 "messages": [],
1192 "tools": [{"type": "function", "function": {
1193 "name": "a",
1194 "parameters": {"type": "object", "additionalProperties": false},
1195 "strict": true,
1196 }}],
1197 }));
1198 assert_ne!(
1199 logical.wire_view().tool_schema_sha256,
1200 shaped.wire_view().tool_schema_sha256,
1201 "strict-mode schema sanitizing must move the wire tool hash"
1202 );
1203
1204 let toolless = prepared(json!({"model": "m", "messages": []}));
1205 assert!(toolless.wire_view().tool_schema_sha256.is_empty());
1206 }
1207
1208 #[test]
1209 fn dialect_labels_are_stable() {
1210 assert_eq!(
1211 WireDialect::from_wire_format(WireFormat::ChatCompletions).as_str(),
1212 "chat-completions"
1213 );
1214 assert_eq!(
1215 WireDialect::from_wire_format(WireFormat::AnthropicMessages).as_str(),
1216 "anthropic-messages"
1217 );
1218 assert_eq!(
1219 WireDialect::from_wire_format(WireFormat::Responses).as_str(),
1220 "openai-responses"
1221 );
1222 }
1223 }
1224
1225 /// Per-dialect proof that `/preview-request` and production dispatch consume
1226 /// the same bytes.
1227 ///
1228 /// Each case builds a real client for a production route, prepares a request
1229 /// through [`CodewhaleClient::prepare_outbound_request`] — the value the
1230 /// transports send and the preview describes — and compares its whole-body
1231 /// hash against the dialect's own builder run over the identically
1232 /// pre-processed request. A divergence here means a second body builder has
1233 /// reappeared.
1234 #[cfg(test)]
1235 mod dialect_seam_tests {
1236 use super::*;
1237 use crate::config::{Config, ProviderConfig, ProvidersConfig};
1238 use codewhale_models::Role;
1239 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool};
1240 use serde_json::json;
1241
1242 use super::super::CodewhaleClient;
1243
1244 fn tool(name: &str) -> Tool {
1245 Tool {
1246 tool_type: None,
1247 name: name.to_string(),
1248 description: format!("{name} description"),
1249 input_schema: json!({"type": "object", "properties": {}}),
1250 allowed_callers: None,
1251 defer_loading: None,
1252 input_examples: None,
1253 strict: None,
1254 cache_control: None,
1255 }
1256 }
1257
1258 fn request(model: &str) -> MessageRequest {
1259 MessageRequest {
1260 model: model.to_string(),
1261 messages: vec![Message {
1262 role: Role::User,
1263 content: vec![ContentBlock::Text {
1264 text: "hello".to_string(),
1265 cache_control: None,
1266 }],
1267 }],
1268 max_tokens: 4096,
1269 system: Some(SystemPrompt::Text("BASE PROMPT".to_string())),
1270 tools: Some(vec![tool("read_file"), tool("Bash")]),
1271 tool_choice: Some(json!({"type": "auto"})),
1272 metadata: None,
1273 thinking: None,
1274 reasoning_effort: Some("high".to_string()),
1275 stream: Some(true),
1276 temperature: None,
1277 top_p: None,
1278 }
1279 }
1280
1281 fn client(provider: &str, configure: impl FnOnce(&mut ProvidersConfig)) -> CodewhaleClient {
1282 let mut providers = ProvidersConfig::default();
1283 configure(&mut providers);
1284 CodewhaleClient::new(&Config {
1285 provider: Some(provider.to_string()),
1286 providers: Some(providers),
1287 ..Config::default()
1288 })
1289 .expect("client resolves for this route")
1290 }
1291
1292 fn configured(api_key: &str, base_url: Option<&str>, model: &str) -> ProviderConfig {
1293 ProviderConfig {
1294 api_key: Some(api_key.to_string()),
1295 base_url: base_url.map(str::to_string),
1296 model: Some(model.to_string()),
1297 ..ProviderConfig::default()
1298 }
1299 }
1300
1301 fn sha256(value: &str) -> String {
1302 crate::hashing::sha256_hex(value.as_bytes())
1303 }
1304
1305 /// The exact pre-processing `prepare_outbound_request` applies before the
1306 /// dialect builder runs. Reproduced here so the reference body is built
1307 /// from the same input, not from a differently-sanitized one.
1308 fn preprocessed(client: &CodewhaleClient, request: MessageRequest) -> MessageRequest {
1309 client
1310 .bind_request_to_protocol(client.prepare_model_bound_request(request))
1311 .expect("protocol binding succeeds")
1312 .0
1313 }
1314
1315 #[test]
1316 fn output_cap_reaches_all_three_wire_dialects_with_reasoning_inside_allowance() {
1317 let _env = crate::test_support::lock_test_env();
1318 for wire in ["chat-completions", "anthropic-messages", "responses"] {
1319 let config = Config {
1320 provider: Some("output-cap-fixture".into()),
1321 providers: Some(ProvidersConfig {
1322 custom: std::collections::HashMap::from([(
1323 "output-cap-fixture".into(),
1324 ProviderConfig {
1325 kind: Some("openai-compatible".into()),
1326 base_url: Some("http://127.0.0.1:18181/v1".into()),
1327 api_key: Some("fixture-output-cap".into()),
1328 model: Some("fixture-model".into()),
1329 wire: Some(wire.into()),
1330 ..Default::default()
1331 },
1332 )]),
1333 ..Default::default()
1334 }),
1335 ..Config::default()
1336 };
1337 let client = CodewhaleClient::new(&config).unwrap();
1338 let mut request = request("fixture-model");
1339 request.max_tokens = 1500;
1340 let prepared = client.prepare_outbound_request(request, true).unwrap();
1341 assert_eq!(prepared.wire_output_cap_tokens(), Some(1500), "{wire}");
1342 if let Some(thinking) = prepared
1343 .body
1344 .pointer("/thinking/budget_tokens")
1345 .and_then(Value::as_u64)
1346 {
1347 assert!(
1348 thinking < 1500,
1349 "reasoning must fit inside the shared allowance"
1350 );
1351 }
1352 }
1353 }
1354
1355 #[test]
1356 fn chat_completions_preview_matches_the_production_chat_builder() {
1357 let client = client("deepseek", |providers| {
1358 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1359 });
1360 let prepared = client
1361 .prepare_outbound_request(request("deepseek-chat"), true)
1362 .expect("chat request prepares");
1363 assert_eq!(prepared.dialect, WireDialect::ChatCompletions);
1364
1365 let reference = super::super::chat::build_chat_wire_body(
1366 &preprocessed(&client, request("deepseek-chat")),
1367 client.api_provider(),
1368 client.base_url(),
1369 true,
1370 )
1371 .expect("reference body builds");
1372
1373 assert_eq!(
1374 prepared.body_sha256(),
1375 sha256(&canonical_json(&reference.body))
1376 );
1377 assert_eq!(prepared.wire_model, reference.model);
1378 assert!(
1379 prepared.body.get("tool_choice").is_none(),
1380 "DeepSeek thinking requests omit tool_choice on the final wire body"
1381 );
1382 }
1383
1384 #[test]
1385 fn kimi_code_keeps_its_own_shape_and_is_not_projected_through_plain_chat() {
1386 let client = client("moonshot", |providers| {
1387 providers.moonshot = configured(
1388 "sk-test-kimi",
1389 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL),
1390 crate::config::KIMI_CODE_K3_MODEL,
1391 );
1392 });
1393 let prepared = client
1394 .prepare_outbound_request(request(crate::config::KIMI_CODE_K3_MODEL), true)
1395 .expect("kimi code request prepares");
1396
1397 assert_eq!(prepared.dialect, WireDialect::ChatCompletions);
1398 assert_eq!(prepared.endpoint.shape, RouteShape::KimiCodeK3);
1399 // The route-specific shaper replaces flat `reasoning_effort` with the
1400 // nested `thinking.effort` dialect; the receipt must show that.
1401 assert_eq!(prepared.reasoning.wire_effort_string(), None);
1402 assert!(
1403 prepared
1404 .reasoning
1405 .wire_controls
1406 .iter()
1407 .any(|(key, _)| key == "thinking"),
1408 "{:?}",
1409 prepared.reasoning
1410 );
1411
1412 let reference = super::super::chat::build_chat_wire_body(
1413 &preprocessed(&client, request(crate::config::KIMI_CODE_K3_MODEL)),
1414 client.api_provider(),
1415 client.base_url(),
1416 true,
1417 )
1418 .expect("reference body builds");
1419 assert_eq!(
1420 prepared.body_sha256(),
1421 sha256(&canonical_json(&reference.body))
1422 );
1423 }
1424
1425 /// Every production Anthropic Messages route: native Anthropic, the
1426 /// DeepSeek Messages route, and the MiniMax Messages route. Each shapes
1427 fn message(role: Role, text: &str) -> Message {
1428 Message {
1429 role,
1430 content: vec![ContentBlock::Text {
1431 text: text.to_string(),
1432 cache_control: None,
1433 }],
1434 }
1435 }
1436
1437 /// Anthropic Messages has no in-transcript `system` role, but a compaction
1438 /// summary cannot be dropped or hoisted without changing transcript
1439 /// meaning. The seam keeps it in place and the adapter projects it onto a
1440 /// user message, one of the two roles the wire accepts.
1441 #[test]
1442 fn seam_preserves_an_in_transcript_system_message_on_anthropic() {
1443 let client = client("anthropic", |providers| {
1444 providers.anthropic = configured("sk-ant-test", None, "claude-sonnet-4-5");
1445 });
1446 let mut request = request("claude-sonnet-4-5");
1447 request
1448 .messages
1449 .push(message(Role::System, "compaction summary"));
1450
1451 let prepared = client
1452 .prepare_outbound_request(request, true)
1453 .expect("Anthropic projects positioned system history onto user");
1454 let carried = prepared.body["messages"]
1455 .as_array()
1456 .expect("messages")
1457 .iter()
1458 .find(|message| {
1459 message["content"].as_array().is_some_and(|blocks| {
1460 blocks
1461 .iter()
1462 .any(|block| block["text"] == "compaction summary")
1463 })
1464 })
1465 .expect("compaction summary survives");
1466 assert_eq!(carried["role"], "user");
1467 }
1468
1469 /// The dialects that have always dropped an unrepresentable role keep
1470 /// dropping it. Turning that into a hard failure would break live
1471 /// sessions; the point of the seam is to make the choice explicit, not to
1472 /// make every dialect strict.
1473 #[test]
1474 fn seam_lets_the_openai_shaped_dialects_keep_dropping_unknown_roles() {
1475 let client = client("deepseek", |providers| {
1476 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1477 });
1478 let mut request = request("deepseek-chat");
1479 request.messages.push(message(
1480 Role::Unrecognized("future_role".to_string()),
1481 "from a newer build",
1482 ));
1483
1484 let prepared = client
1485 .prepare_outbound_request(request, true)
1486 .expect("an unknown role must not fail a Chat Completions turn");
1487 let body = serde_json::to_string(&prepared.body).expect("serialize body");
1488 assert!(
1489 !body.contains("from a newer build"),
1490 "an unknown role must not reach the wire: {body}"
1491 );
1492 assert!(!body.contains("\"future_role\""), "{body}");
1493 }
1494
1495 /// thinking differently, so each is checked against its own builder run.
1496 #[test]
1497 fn anthropic_messages_preview_matches_the_production_messages_builder() {
1498 type ProviderCase = (
1499 &'static str,
1500 &'static str,
1501 Box<dyn Fn(&mut ProvidersConfig)>,
1502 );
1503
1504 let cases: Vec<ProviderCase> = vec![
1505 (
1506 "anthropic",
1507 "claude-sonnet-4-5",
1508 Box::new(|providers: &mut ProvidersConfig| {
1509 providers.anthropic = configured("sk-ant-test", None, "claude-sonnet-4-5");
1510 }),
1511 ),
1512 (
1513 "deepseek-anthropic",
1514 "deepseek-v4",
1515 Box::new(|providers: &mut ProvidersConfig| {
1516 providers.deepseek_anthropic =
1517 configured("sk-test-deepseek-anthropic", None, "deepseek-v4");
1518 }),
1519 ),
1520 (
1521 "minimax-anthropic",
1522 "MiniMax-M3",
1523 Box::new(|providers: &mut ProvidersConfig| {
1524 providers.minimax_anthropic =
1525 configured("sk-test-minimax-anthropic", None, "MiniMax-M3");
1526 }),
1527 ),
1528 ];
1529
1530 for (provider, model, configure) in cases {
1531 let client = client(provider, |providers| configure(providers));
1532 let prepared = client
1533 .prepare_outbound_request(request(model), true)
1534 .unwrap_or_else(|error| panic!("{provider} request prepares: {error}"));
1535
1536 assert_eq!(
1537 prepared.dialect,
1538 WireDialect::AnthropicMessages,
1539 "{provider} must keep the Messages dialect, not be projected through Chat"
1540 );
1541
1542 let reference =
1543 client.build_anthropic_body(&preprocessed(&client, request(model)), true);
1544 assert_eq!(
1545 prepared.body_sha256(),
1546 sha256(&canonical_json(&reference)),
1547 "{provider} preview body must hash identically to the production builder"
1548 );
1549 assert_eq!(
1550 prepared
1551 .body
1552 .get("tool_choice")
1553 .and_then(|value| value.get("type"))
1554 .and_then(serde_json::Value::as_str),
1555 Some("auto"),
1556 "{provider} tool_choice must come from the final Messages body"
1557 );
1558
1559 // The Messages dialect never carries a flat `reasoning_effort`;
1560 // the receipt must reflect the dialect's own controls.
1561 assert_eq!(prepared.reasoning.wire_effort_string(), None, "{provider}");
1562 assert_eq!(
1563 prepared.reasoning.requested_effort.as_deref(),
1564 Some("high"),
1565 "{provider}"
1566 );
1567 }
1568 }
1569
1570 /// Codex resolves its bearer through OAuth, so the test pins a token the
1571 /// same way the Responses adapter's own tests do.
1572 fn codex_client() -> CodewhaleClient {
1573 let _env_lock = crate::test_support::lock_test_env();
1574 let _codex_token =
1575 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", "test-token");
1576 let _legacy_codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
1577 client("openai-codex", |providers| {
1578 providers.openai_codex = configured("", None, "gpt-5-codex");
1579 })
1580 }
1581
1582 #[test]
1583 fn responses_preview_matches_the_production_responses_builder() {
1584 let client = codex_client();
1585 let prepared = client
1586 .prepare_outbound_request(request("gpt-5-codex"), true)
1587 .expect("responses request prepares");
1588
1589 assert_eq!(prepared.dialect, WireDialect::OpenAiResponses);
1590 assert_eq!(prepared.endpoint.shape, RouteShape::CodexResponses);
1591
1592 let reference = super::super::responses::build_responses_body(&preprocessed(
1593 &client,
1594 request("gpt-5-codex"),
1595 ));
1596 assert_eq!(prepared.body_sha256(), sha256(&canonical_json(&reference)));
1597 assert_eq!(prepared.body.get("tool_choice"), Some(&json!("auto")));
1598 }
1599
1600 #[test]
1601 fn every_dialect_reports_a_distinct_body_hash_for_the_same_logical_request() {
1602 // Guards against the reviewed failure mode: projecting every route
1603 // through the Chat builder would make these collide.
1604 let chat = client("deepseek", |providers| {
1605 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1606 })
1607 .prepare_outbound_request(request("deepseek-chat"), true)
1608 .expect("chat prepares");
1609 let codex = codex_client();
1610 let responses = codex
1611 .prepare_outbound_request(request("gpt-5-codex"), true)
1612 .expect("responses prepares");
1613
1614 assert_ne!(chat.dialect, responses.dialect);
1615 assert_ne!(chat.body_sha256(), responses.body_sha256());
1616 }
1617
1618 #[test]
1619 fn streaming_and_blocking_bodies_are_distinguished_not_conflated() {
1620 let client = client("deepseek", |providers| {
1621 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1622 });
1623 let streaming = client
1624 .prepare_outbound_request(request("deepseek-chat"), true)
1625 .expect("streaming prepares");
1626 let blocking = client
1627 .prepare_outbound_request(request("deepseek-chat"), false)
1628 .expect("blocking prepares");
1629
1630 assert_eq!(streaming.entrypoint, CallerStreamMode::Streaming);
1631 assert_eq!(blocking.entrypoint, CallerStreamMode::Blocking);
1632 // Chat is the dialect where caller mode and wire fact agree: the
1633 // streaming body sets `stream: true`, the blocking body omits it.
1634 assert_eq!(streaming.wire_stream_field(), Some(true));
1635 assert_eq!(blocking.wire_stream_field(), None);
1636 assert_ne!(streaming.body_sha256(), blocking.body_sha256());
1637 }
1638
1639 /// #1004 review finding: the Responses blocking entry point opens an SSE
1640 /// stream and folds it into one response, so its body says
1641 /// `"stream": true` while the caller mode is blocking. The manifest must
1642 /// read the body, never the caller mode.
1643 #[test]
1644 fn responses_wire_streaming_is_read_from_the_body_not_the_caller_mode() {
1645 let client = codex_client();
1646 let blocking = client
1647 .prepare_outbound_request(request("gpt-5-codex"), false)
1648 .expect("blocking responses prepares");
1649
1650 assert_eq!(blocking.entrypoint, CallerStreamMode::Blocking);
1651 assert_eq!(
1652 blocking.wire_stream_field(),
1653 Some(true),
1654 "the Responses blocking path genuinely sends an SSE body"
1655 );
1656
1657 let streaming = client
1658 .prepare_outbound_request(request("gpt-5-codex"), true)
1659 .expect("streaming responses prepares");
1660 assert_eq!(streaming.wire_stream_field(), Some(true));
1661 assert_eq!(
1662 streaming.body_sha256(),
1663 blocking.body_sha256(),
1664 "the two Responses entry points send the same bytes; only the \
1665 caller mode differs"
1666 );
1667 }
1668
1669 #[test]
1670 fn preparation_is_deterministic_across_repeated_calls() {
1671 let client = client("deepseek", |providers| {
1672 providers.deepseek = configured("sk-test-deepseek", None, "deepseek-chat");
1673 });
1674 let first = client
1675 .prepare_outbound_request(request("deepseek-chat"), true)
1676 .expect("first prepares");
1677 let second = client
1678 .prepare_outbound_request(request("deepseek-chat"), true)
1679 .expect("second prepares");
1680 assert_eq!(first.body_sha256(), second.body_sha256());
1681 assert_eq!(first.endpoint_fingerprint(), second.endpoint_fingerprint());
1682 }
1683 }
1684
1684 lines RUST