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