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