Skip to main content

rig_core/completion/options/
merge.rs

1//! The one merge of a completion request body: the wire's own encoding of
2//! the request, then the mapped options, then the provider options, then
3//! `additional_params`, then the closed list of [`Rewrite`]s.
4//! [`request_params`] is the only function that builds a [`FinalBody`], and
5//! [`check`] reports every refused option before a wire encodes.
6
7use std::borrow::Cow;
8
9use serde::de::DeserializeOwned;
10use serde::ser::SerializeMap;
11use serde::{Serialize, Serializer};
12use serde_json::{Map, Value};
13
14use super::mapping::{Mapping, OptionName};
15use super::{CacheRetention, OnUnsupported, UnsupportedOption};
16use crate::completion::provider_options::SHARED;
17use crate::completion::{CompletionRequest, ReplayTarget};
18use crate::error::EncodeError;
19use crate::json_utils::remove_preserving_order;
20use crate::providers::openai::wire::BodyRewrite;
21use crate::wire::Body;
22
23/// The merged request body. Only [`request_params`] builds one.
24/// Serialization writes fields named by [`Rewrite::MoveLast`] after the
25/// other fields, in rewrite order.
26#[derive(Clone, Debug, PartialEq)]
27pub struct FinalBody {
28    fields: Map<String, Value>,
29    last: Vec<&'static str>,
30}
31
32impl Serialize for FinalBody {
33    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
34        let mut map = serializer.serialize_map(Some(self.fields.len()))?;
35        for (key, value) in &self.fields {
36            if !self.last.contains(&key.as_str()) {
37                map.serialize_entry(key, value)?;
38            }
39        }
40        for key in &self.last {
41            if let Some(value) = self.fields.get(*key) {
42                map.serialize_entry(key, value)?;
43            }
44        }
45        map.end()
46    }
47}
48
49impl FinalBody {
50    /// Whether the body has no key.
51    pub fn is_empty(&self) -> bool {
52        self.fields.is_empty()
53    }
54
55    /// The top-level value of `key`.
56    pub fn get(&self, key: &str) -> Option<&Value> {
57        self.fields.get(key)
58    }
59
60    /// The value at the JSON pointer `pointer`.
61    pub fn pointer(&self, pointer: &str) -> Option<&Value> {
62        let path = pointer.strip_prefix('/')?;
63        let (head, rest) = path.split_once('/').unwrap_or((path, ""));
64        let value = self
65            .fields
66            .get(&head.replace("~1", "/").replace("~0", "~"))?;
67        if rest.is_empty() {
68            Some(value)
69        } else {
70            value.pointer(&format!("/{rest}"))
71        }
72    }
73
74    /// The request bytes of an HTTP completion wire: the body as compact
75    /// JSON.
76    pub fn into_body(self) -> Body {
77        let mut bytes = Vec::new();
78        // JSON values and string keys serialize infallibly into a byte vector.
79        let _ = serde_json::to_writer(&mut bytes, &self);
80        Body::Bytes(bytes)
81    }
82
83    /// The body read as `T`: an SDK-backed wire's request type, or a local
84    /// runtime's generation settings.
85    ///
86    /// # Errors
87    ///
88    /// When the body is not a `T`.
89    pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
90        // Read in place: a `Value` of the body would copy the whole history.
91        let fields = self.fields.iter().map(|(key, value)| (key.as_str(), value));
92        T::deserialize(serde::de::value::MapDeserializer::new(fields))
93    }
94}
95
96/// Where the raw layer, `additional_params`, merges. Data, not code.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum RawAt {
100    /// At the top level of the body.
101    Top,
102    /// Under the object at this JSON pointer (Bedrock's
103    /// `/additionalModelRequestFields`).
104    Under(&'static str),
105    /// The keys in `top`, and the key `rest` names, at the top level; every
106    /// other key under `rest` (Ollama's native `options`).
107    Split {
108        /// The keys the wire reads at the top level.
109        top: &'static [&'static str],
110        /// The object every other key goes under.
111        rest: &'static str,
112    },
113    /// Not merged: the provider rejects pass-through parameters, so a
114    /// non-empty raw layer is skipped with this warning.
115    ///
116    /// For rig's own crates; not covered by semver.
117    #[doc(hidden)]
118    Ignored(&'static str),
119}
120
121/// The writes [`request_params`] makes after the merge, each one a write a
122/// wire made after merging `additional_params` before. Each reads the merged
123/// body, raw keys included. A third-party wire uses the stream ones; the
124/// provider-specific ones are rig's own and hidden.
125#[derive(Clone, Debug, PartialEq)]
126#[non_exhaustive]
127pub enum Rewrite {
128    /// Chat Completions: `max_tokens` as `max_completion_tokens` for an
129    /// OpenAI reasoning model.
130    ///
131    /// For rig's own crates; not covered by semver.
132    #[doc(hidden)]
133    OutputCapRename,
134    /// Anthropic: ask to drop thinking bound to another context, when the
135    /// body's thinking is adaptive or unset.
136    ///
137    /// For rig's own crates; not covered by semver.
138    #[doc(hidden)]
139    DropUnboundThinking,
140    /// Anthropic on a platform whose URL names the model: `model` removed
141    /// and `anthropic_version` set to this value.
142    ///
143    /// For rig's own crates; not covered by semver.
144    #[doc(hidden)]
145    AnthropicVersionInBody(String),
146    /// Anthropic streams: a default `tool_choice` beside tools, none without
147    /// them.
148    ///
149    /// For rig's own crates; not covered by semver.
150    #[doc(hidden)]
151    ToolChoiceNeedsTools,
152    /// `stream` set to this value.
153    Stream(bool),
154    /// `stream` removed.
155    NoStream,
156    /// OpenAI Responses over a WebSocket session: `background` removed,
157    /// which the session ignores.
158    ///
159    /// For rig's own crates; not covered by semver.
160    #[doc(hidden)]
161    NoBackground,
162    /// Chat Completions streams: `stream_options.include_usage: true`
163    /// unless the body states it.
164    StreamUsage,
165    /// OpenAI Responses: ask for the reasoning ciphertext when the body
166    /// reasons or stores nothing, or always when `true`.
167    ///
168    /// For rig's own crates; not covered by semver.
169    #[doc(hidden)]
170    ReasoningCiphertext(bool),
171    /// OpenAI Responses on ChatGPT: `store: false`.
172    ///
173    /// For rig's own crates; not covered by semver.
174    #[doc(hidden)]
175    CodexStore,
176    /// The Chat Completions dialect's own rewrite.
177    ///
178    /// For rig's own crates; not covered by semver.
179    #[doc(hidden)]
180    ChatDialect(BodyRewrite),
181    /// Gemini `cachedContent`: each raw `cachedContent`/`cached_content`
182    /// handle, then the wire's own, checked against the body. The
183    /// GenerateContent wires that name it also read a raw
184    /// `generationConfig: null` as absent, as they always have.
185    ///
186    /// For rig's own crates; not covered by semver.
187    #[doc(hidden)]
188    GeminiCachedContent(Option<String>),
189    /// The top-level `key` moved to the end of the body, so a request that
190    /// only appended to it starts with the previous request's bytes.
191    ///
192    /// For rig's own crates; not covered by semver.
193    #[doc(hidden)]
194    MoveLast(&'static str),
195}
196
197/// The base builder's view of the options and the layers above it. It
198/// cannot write them.
199pub struct BaseInput<'a> {
200    target: &'a dyn ReplayTarget,
201    request: &'a CompletionRequest,
202    cache: Option<CacheRetention>,
203    upper: &'a Map<String, Value>,
204    provider: &'a Map<String, Value>,
205    claimed: Vec<&'static str>,
206    raw_tools: Option<Value>,
207}
208
209impl BaseInput<'_> {
210    /// The cache retention the mapping honoured through `Send` or `Place`,
211    /// for markers inside the body's arrays.
212    pub fn cache(&self) -> Option<CacheRetention> {
213        self.cache
214    }
215
216    /// Report a cache marker the base cannot place, under the name `cache`:
217    /// an error under [`OnUnsupported::Error`], a warning under
218    /// [`OnUnsupported::Ignore`].
219    ///
220    /// # Errors
221    ///
222    /// Under [`OnUnsupported::Error`].
223    pub fn refuse_cache(&mut self, reason: impl Into<String>) -> Result<(), EncodeError> {
224        refuse(self.target, self.request, "cache", reason.into())
225    }
226
227    /// The value top-level `key` gets from the layers above the base: the
228    /// mapped options, then the provider options, then `additional_params`,
229    /// merged as the body merges them. Read only, so it cannot rank
230    /// anything.
231    pub fn param(&self, key: &str) -> Option<&Value> {
232        self.upper.get(key)
233    }
234
235    /// The provider options' top-level `key`, which the base places itself.
236    /// The merge then leaves it out of the provider layer, so it does not
237    /// replace that placement; `additional_params` still merges over it.
238    pub fn claim(&mut self, key: &'static str) -> Option<Value> {
239        self.claimed.push(key);
240        self.provider.get(key).cloned()
241    }
242
243    /// `additional_params.tools`, taken out of the raw layer for the base to
244    /// append after the request's own tools, so the merge never replaces
245    /// them. Empty when it is unset.
246    ///
247    /// # Errors
248    ///
249    /// When it is not an array.
250    pub fn raw_tools(&mut self) -> Result<Vec<Value>, EncodeError> {
251        match self.raw_tools.take() {
252            None | Some(Value::Null) => Ok(Vec::new()),
253            Some(Value::Array(tools)) => Ok(tools),
254            Some(other) => Err(EncodeError::request(format!(
255                "`additional_params.tools` must be an array, got {other}"
256            ))),
257        }
258    }
259}
260
261/// The answers of a wire for a request's options, after the policy.
262#[derive(Default)]
263struct Settled {
264    /// Each `Send` object, in option order.
265    sends: Vec<Value>,
266    /// The retention honoured by `Send` or `Place`.
267    cache: Option<CacheRetention>,
268    /// The options skipped under `Ignore`.
269    ignored: Vec<OptionName>,
270}
271
272/// The model the request resolves to on `target`.
273fn model_of<'a>(target: &'a dyn ReplayTarget, request: &'a CompletionRequest) -> &'a str {
274    request
275        .model
276        .as_deref()
277        .filter(|model| !model.is_empty())
278        .unwrap_or_else(|| target.model())
279}
280
281/// Report `option` as unsupported for `reason` under the request's policy.
282fn refuse(
283    target: &dyn ReplayTarget,
284    request: &CompletionRequest,
285    option: impl Into<Cow<'static, str>>,
286    reason: String,
287) -> Result<(), EncodeError> {
288    let option = option.into();
289    let provider = target.provider();
290    let model = model_of(target, request);
291    if collected(|| UnsupportedOption::new(option.clone(), provider, model, reason.clone())) {
292        return Ok(());
293    }
294    match request.options.unsupported_policy() {
295        OnUnsupported::Error => Err(EncodeError::unsupported(UnsupportedOption::new(
296            option, provider, model, reason,
297        ))),
298        OnUnsupported::Ignore => {
299            tracing::warn!(
300                option = option.as_ref(),
301                provider,
302                model,
303                reason = reason.as_str(),
304                "option skipped: the provider cannot honour it"
305            );
306            Ok(())
307        }
308    }
309}
310
311/// `target`'s answer for every option of `request`, with each refusal
312/// reported under the request's policy.
313fn settle(target: &dyn ReplayTarget, request: &CompletionRequest) -> Result<Settled, EncodeError> {
314    let fields = request.options.fields();
315    let map = target.map_options(request, fields);
316    let provider = target.provider();
317    let mut settled = Settled::default();
318    for (option, mapping) in map.into_slots() {
319        let set = fields.is_set(option);
320        let name = option.as_str();
321        let fail = |what: String| Err(EncodeError::request(format!("{provider} {what}")));
322        match (mapping, set) {
323            (Mapping::Nothing, false) => {}
324            (Mapping::Nothing, true) => {
325                return fail(format!(
326                    "answered `Mapping::Nothing` for the option `{name}`, which the request sets"
327                ));
328            }
329            (_, false) => {
330                return fail(format!(
331                    "answered the option `{name}`, which the request does not set; \
332                     an unset option answers `Mapping::Nothing` (see `Mapping::of`)"
333                ));
334            }
335            (Mapping::Send(value), true) => {
336                if !value.is_object() {
337                    return fail(format!(
338                        "sent a value that is not a JSON object for the option `{name}`"
339                    ));
340                }
341                settled.sends.push(value);
342                if option == OptionName::Cache {
343                    settled.cache = request.options.cache;
344                }
345            }
346            (Mapping::Omit(reason), true) => {
347                tracing::debug!(
348                    option = name,
349                    provider,
350                    reason,
351                    "option honoured by sending nothing"
352                );
353            }
354            (Mapping::Place, true) if option == OptionName::Cache => {
355                settled.cache = request.options.cache
356            }
357            (Mapping::Place, true) => {
358                return fail(format!(
359                    "answered `Mapping::Place` for the option `{name}`; only `cache` places markers"
360                ));
361            }
362            (Mapping::Unsupported(reason), true) => {
363                refuse(target, request, name, reason)?;
364                settled.ignored.push(option);
365            }
366        }
367    }
368    Ok(settled)
369}
370
371/// Report each option `target` cannot honour for `request` under its
372/// policy: an error under [`OnUnsupported::Error`]; under
373/// [`OnUnsupported::Ignore`] a warning, and the option is cleared on
374/// `request`, so it is reported once. A set option the wire answers
375/// [`Mapping::Nothing`] is an error. `Completion::prepare` calls it on the
376/// routed target before every completion encode.
377///
378/// # Errors
379///
380/// When an option is refused under [`OnUnsupported::Error`], the wire
381/// answers an option wrongly, or the request carries provider options that
382/// did not serialize.
383pub fn check(
384    target: &dyn ReplayTarget,
385    request: &mut CompletionRequest,
386) -> Result<(), EncodeError> {
387    unserialized(request)?;
388    let settled = settle(target, request)?;
389    for option in settled.ignored {
390        option.clear(request);
391    }
392    model_rules(target, request)?;
393    let layer = provider_layer(target, request);
394    for refusal in &layer.refused {
395        refuse(
396            target,
397            request,
398            refusal.name(target),
399            refusal.reason.clone(),
400        )?;
401    }
402    let api = target.api();
403    for refusal in layer.refused {
404        request.provider_options.remove_field(
405            target.provider(),
406            &[SHARED, api.as_str()],
407            refusal.field,
408        );
409    }
410    Ok(())
411}
412
413/// Report each option of `request` the catalog entry of its model on
414/// `target` refuses by [`ModelSpec::refusals`](crate::catalog::ModelSpec::refusals),
415/// under the request's policy, and clear it under
416/// [`OnUnsupported::Ignore`]. The wire's own answers come first, so an
417/// option it already refused is not reported twice.
418fn model_rules(
419    target: &dyn ReplayTarget,
420    request: &mut CompletionRequest,
421) -> Result<(), EncodeError> {
422    let facts = target
423        .facts()
424        .unwrap_or_else(|| crate::catalog::ModelFacts::builtin());
425    let Some(spec) = facts.for_model(target.provider(), model_of(target, request)) else {
426        return Ok(());
427    };
428    for CatalogRefusal { option, reason } in spec.request_rules(request) {
429        refuse(target, request, option.as_str(), reason)?;
430        option.clear(request);
431    }
432    Ok(())
433}
434
435thread_local! {
436    /// The refusals collected by [`collect_refusals`] on this thread, while
437    /// it runs.
438    static COLLECTED: std::cell::RefCell<Option<Vec<UnsupportedOption>>> =
439        const { std::cell::RefCell::new(None) };
440}
441
442/// Run `encode` with every refusal collected instead of reported: each
443/// refused option is skipped as under [`OnUnsupported::Ignore`], without a
444/// warning, and the refusals come back in the order they were made. Encoding
445/// is synchronous and pure, so a thread-local scope sees every refusal it
446/// makes. `DynModel::check` dry-runs a request under it.
447pub(crate) fn collect_refusals<T>(encode: impl FnOnce() -> T) -> (T, Vec<UnsupportedOption>) {
448    /// Restores the enclosing scope's collection, also on unwind.
449    struct Scope(Option<Vec<UnsupportedOption>>);
450    impl Drop for Scope {
451        fn drop(&mut self) {
452            let outer = self.0.take();
453            COLLECTED.with(|collected| collected.replace(outer));
454        }
455    }
456    let outer = COLLECTED.with(|collected| collected.replace(Some(Vec::new())));
457    let scope = Scope(outer);
458    let value = encode();
459    let refusals = COLLECTED
460        .with(|collected| collected.borrow_mut().take())
461        .unwrap_or_default();
462    drop(scope);
463    (value, refusals)
464}
465
466/// Whether a [`collect_refusals`] scope is open, after adding the refusal
467/// `make` builds to it.
468fn collected(make: impl FnOnce() -> UnsupportedOption) -> bool {
469    COLLECTED.with(|collected| match collected.borrow_mut().as_mut() {
470        Some(refusals) => {
471            refusals.push(make());
472            true
473        }
474        None => false,
475    })
476}
477
478/// An option a model's catalog entry refuses, and why.
479pub(crate) struct CatalogRefusal {
480    pub(crate) option: OptionName,
481    pub(crate) reason: String,
482}
483
484/// A part of a final body that a model's catalog entry says the API
485/// rejects: a top-level body key, or `tools` for the request's tools, and
486/// why. Found on the final body, because `additional_params` can set the
487/// same keys.
488pub(crate) struct BodyRefusal {
489    pub(crate) field: &'static str,
490    pub(crate) reason: String,
491}
492
493/// `body` after the refusals `target`'s catalog entry for the model makes
494/// of it. A request that sets no [`GenerationOptions`] is sent as built,
495/// and the provider decides. Otherwise each refusal goes through the
496/// request's policy under the name of its body key: an error under
497/// [`OnUnsupported::Error`]; under [`OnUnsupported::Ignore`] a warning, and
498/// a field rig wrote from a typed source (a request field such as
499/// `temperature`, a generation option, a provider option) is left out of
500/// the body. A key whose value `additional_params` sets, and the request's
501/// `tools`, are sent as written.
502///
503/// Inside [`collect_refusals`], every refusal is collected and handled as
504/// under [`OnUnsupported::Ignore`], so a dry run lists them all.
505///
506/// # Errors
507///
508/// Under [`OnUnsupported::Error`], for the first refusal.
509///
510/// [`GenerationOptions`]: super::GenerationOptions
511pub(crate) fn catalog_refusals(
512    target: &dyn ReplayTarget,
513    request: &CompletionRequest,
514    body: FinalBody,
515    refusals: Vec<BodyRefusal>,
516) -> Result<FinalBody, EncodeError> {
517    let provider = target.provider();
518    let model = model_of(target, request);
519    if request.options.is_default() {
520        for refusal in &refusals {
521            tracing::debug!(
522                option = refusal.field,
523                provider,
524                model,
525                reason = refusal.reason.as_str(),
526                "catalog refusal not applied: the request sets no generation options"
527            );
528        }
529        return Ok(body);
530    }
531    let mut body = body;
532    for BodyRefusal { field, reason } in refusals {
533        let collecting =
534            collected(|| UnsupportedOption::new(field, provider, model, reason.clone()));
535        if !collecting && request.options.unsupported_policy() == OnUnsupported::Error {
536            return Err(EncodeError::unsupported(UnsupportedOption::new(
537                field, provider, model, reason,
538            )));
539        }
540        let raw = request
541            .additional_params
542            .as_ref()
543            .and_then(|params| params.get(field));
544        let typed = field != "tools" && raw.is_none_or(|raw| body.get(field) != Some(raw));
545        if typed {
546            remove_preserving_order(&mut body.fields, field);
547        }
548        if collecting {
549            continue;
550        }
551        tracing::warn!(
552            option = field,
553            provider,
554            model,
555            reason = reason.as_str(),
556            "{}",
557            match typed {
558                true => "option skipped: the model's catalog entry refuses it",
559                false => "catalog refusal ignored: sent as written",
560            }
561        );
562    }
563    Ok(body)
564}
565
566/// A provider field the target cannot send: its body key, the section it
567/// came from and why.
568struct Refused {
569    field: &'static str,
570    section: String,
571    reason: String,
572}
573
574impl Refused {
575    /// The field's name, `"<provider>.<section>.<field>"`.
576    fn name(&self, target: &dyn ReplayTarget) -> String {
577        format!("{}.{}.{}", target.provider(), self.section, self.field)
578    }
579}
580
581/// The provider options `target` reads from `request`, with the fields it
582/// cannot send taken out.
583struct ProviderLayer {
584    fields: Map<String, Value>,
585    refused: Vec<Refused>,
586}
587
588/// The entry of `request`'s provider options named by `target`'s provider:
589/// its [`SHARED`] section, then the section named by `target`'s API, merged.
590/// A section for another route is skipped. Each set field the entry's
591/// typed options refuse for `target` is taken out and listed.
592fn provider_layer(target: &dyn ReplayTarget, request: &CompletionRequest) -> ProviderLayer {
593    let provider = target.provider();
594    let api = target.api();
595    let mut layer = ProviderLayer {
596        fields: Map::new(),
597        refused: Vec::new(),
598    };
599    let Some(sections) = request.provider_options.sections(provider) else {
600        return layer;
601    };
602    let mut route = None;
603    for (name, section) in sections {
604        let Value::Object(section) = section else {
605            continue;
606        };
607        if name == SHARED {
608            deep_merge(&mut layer.fields, section.clone());
609        } else if name == api.as_str() {
610            route = Some(section);
611        } else {
612            tracing::debug!(
613                provider,
614                section = name.as_str(),
615                api = api.as_str(),
616                "provider options section skipped: the request takes another route"
617            );
618        }
619    }
620    if let Some(route) = route {
621        deep_merge(&mut layer.fields, route.clone());
622    }
623    for (field, reason) in request.provider_options.refusals(provider, target, request) {
624        layer.refuse(target, request, field, reason);
625    }
626    layer
627}
628
629impl ProviderLayer {
630    /// Take `field` out and list it as refused for `reason`, when the layer
631    /// sets it. It is named by the section it came from: the route's when
632    /// that section sets it, [`SHARED`] otherwise.
633    fn refuse(
634        &mut self,
635        target: &dyn ReplayTarget,
636        request: &CompletionRequest,
637        field: &'static str,
638        reason: String,
639    ) {
640        if remove_preserving_order(&mut self.fields, field).is_none() {
641            return;
642        }
643        let api = target.api();
644        let in_route = request
645            .provider_options
646            .sections(target.provider())
647            .and_then(|sections| sections.get(api.as_str()))
648            .and_then(Value::as_object)
649            .is_some_and(|route| route.contains_key(field));
650        let section = if in_route { api.as_str() } else { SHARED };
651        self.refused.push(Refused {
652            field,
653            section: section.to_owned(),
654            reason,
655        });
656    }
657}
658
659/// The error of provider options [`ProviderOptions::set`] stored without
660/// sections, as an encode error.
661///
662/// [`ProviderOptions::set`]: crate::completion::ProviderOptions::set
663fn unserialized(request: &CompletionRequest) -> Result<(), EncodeError> {
664    match request.provider_options.failure() {
665        Some(error) => Err(EncodeError::request(error.clone())),
666        None => Ok(()),
667    }
668}
669
670/// `additional_params` as an object, or empty.
671fn raw_layer(request: &CompletionRequest) -> Result<Map<String, Value>, EncodeError> {
672    match &request.additional_params {
673        None | Some(Value::Null) => Ok(Map::new()),
674        Some(Value::Object(params)) => Ok(params.clone()),
675        Some(_) => Err(EncodeError::request(
676            "`additional_params` must be a JSON object",
677        )),
678    }
679}
680
681/// `raw` placed in the body as `raw_at` says.
682fn placed(raw: Map<String, Value>, raw_at: RawAt) -> Map<String, Value> {
683    match raw_at {
684        RawAt::Top => raw,
685        RawAt::Under(_) if raw.is_empty() => Map::new(),
686        RawAt::Under(pointer) => {
687            let mut value = Value::Object(raw);
688            for key in pointer.rsplit('/').filter(|key| !key.is_empty()) {
689                value = Value::Object(Map::from_iter([(key.to_owned(), value)]));
690            }
691            match value {
692                Value::Object(map) => map,
693                _ => Map::new(),
694            }
695        }
696        RawAt::Split { top, rest } => {
697            let mut body = Map::new();
698            let mut under = Map::new();
699            for (key, value) in raw {
700                if key == rest || top.contains(&key.as_str()) {
701                    deep_merge(&mut body, Map::from_iter([(key, value)]));
702                } else {
703                    under.insert(key, value);
704                }
705            }
706            if !under.is_empty() {
707                deep_merge(
708                    &mut body,
709                    Map::from_iter([(rest.to_owned(), Value::Object(under))]),
710                );
711            }
712            body
713        }
714        RawAt::Ignored(warning) => {
715            if !raw.is_empty() {
716                tracing::warn!("{warning}");
717            }
718            Map::new()
719        }
720    }
721}
722
723/// Merge `upper` into `body`: objects key by key, recursively; any other
724/// value, `null` and arrays included, replaces.
725pub(crate) fn deep_merge(body: &mut Map<String, Value>, upper: Map<String, Value>) {
726    for (key, value) in upper {
727        match (body.get_mut(&key), value) {
728            (Some(Value::Object(lower)), Value::Object(upper)) => deep_merge(lower, upper),
729            (_, value) => {
730                body.insert(key, value);
731            }
732        }
733    }
734}
735
736/// The layers above the base, merged: the mapped options, then the
737/// provider options, then the raw layer as placed.
738fn upper_layers(
739    sends: &[Value],
740    provider: &Map<String, Value>,
741    raw: &Map<String, Value>,
742) -> Map<String, Value> {
743    let mut upper = Map::new();
744    for send in sends {
745        if let Value::Object(send) = send {
746            deep_merge(&mut upper, send.clone());
747        }
748    }
749    deep_merge(&mut upper, provider.clone());
750    deep_merge(&mut upper, raw.clone());
751    upper
752}
753
754/// The value top-level `key` gets from the mapped options, the provider
755/// options and `additional_params`, merged as [`request_params`] merges
756/// them, with no base. For a reader that runs before encoding and must
757/// agree with the body. Read only, so it cannot rank anything, and it
758/// reports nothing: a refused option adds nothing here. The one sanctioned
759/// reader of these layers on the completion path outside the merge. A
760/// wire's `map_options` must not call it, since it calls `map_options`.
761pub fn param(target: &dyn ReplayTarget, request: &CompletionRequest, key: &str) -> Option<Value> {
762    let map = target.map_options(request, request.options.fields());
763    let mut upper = Map::new();
764    for (_, mapping) in map.into_slots() {
765        if let Mapping::Send(Value::Object(send)) = mapping {
766            deep_merge(&mut upper, send);
767        }
768    }
769    deep_merge(&mut upper, provider_layer(target, request).fields);
770    if let Some(Value::Object(raw)) = &request.additional_params {
771        deep_merge(&mut upper, raw.clone());
772    }
773    remove_preserving_order(&mut upper, key)
774}
775
776/// The body `target` sends for `request`. Reports each refused option
777/// (after `Completion::prepare` none is left), then calls `base` for the
778/// wire's own encoding of the request, merges the mapped options, then the
779/// provider options at the top level, then `additional_params` as `raw_at`
780/// places them, and applies `rewrites` in order. The only reader of the
781/// request's options, provider options and `additional_params` on the
782/// completion path.
783///
784/// # Errors
785///
786/// When an option or a provider field is refused under
787/// [`OnUnsupported::Error`], the request carries provider options that did
788/// not serialize,
789/// `additional_params` is not an object or holds a malformed `tools` or
790/// cached-content handle, `base` fails, or a rewrite refuses the body.
791pub fn request_params(
792    target: &dyn ReplayTarget,
793    request: &CompletionRequest,
794    base: impl FnOnce(&mut BaseInput<'_>) -> Result<Map<String, Value>, EncodeError>,
795    raw_at: RawAt,
796    rewrites: &[Rewrite],
797) -> Result<FinalBody, EncodeError> {
798    unserialized(request)?;
799    let settled = settle(target, request)?;
800    let mut provider = provider_layer(target, request);
801    // The rewrite drops a raw `background` as it always has; a typed one is
802    // refused rather than dropped unseen.
803    if rewrites.contains(&Rewrite::NoBackground) {
804        provider.refuse(
805            target,
806            request,
807            "background",
808            "a WebSocket session takes no `background`".to_owned(),
809        );
810    }
811    for refusal in provider.refused {
812        refuse(target, request, refusal.name(target), refusal.reason)?;
813    }
814    let mut provider = provider.fields;
815    let mut raw = raw_layer(request)?;
816    let raw_tools = match raw_at {
817        RawAt::Top | RawAt::Split { .. } => remove_preserving_order(&mut raw, "tools"),
818        RawAt::Under(_) | RawAt::Ignored(_) => None,
819    };
820    let mut handles = Vec::new();
821    if rewrites
822        .iter()
823        .any(|rewrite| matches!(rewrite, Rewrite::GeminiCachedContent(_)))
824    {
825        for spelling in ["cachedContent", "cached_content"] {
826            match remove_preserving_order(&mut raw, spelling) {
827                None => {}
828                Some(Value::String(name)) => handles.push(name),
829                Some(other) => {
830                    return Err(EncodeError::request(format!(
831                        "Gemini `additional_params.{spelling}` should be a string, got {other}"
832                    )));
833                }
834            }
835        }
836        // GenerateContent has always read a raw `generationConfig: null` as
837        // absent, so the typed and mapped fields under it are still sent.
838        if raw.get("generationConfig").is_some_and(Value::is_null) {
839            remove_preserving_order(&mut raw, "generationConfig");
840        }
841    }
842    let raw = placed(raw, raw_at);
843    let upper = upper_layers(&settled.sends, &provider, &raw);
844    let mut input = BaseInput {
845        target,
846        request,
847        cache: settled.cache,
848        upper: &upper,
849        provider: &provider,
850        claimed: Vec::new(),
851        raw_tools,
852    };
853    let mut body = base(&mut input)?;
854    for key in input.claimed {
855        remove_preserving_order(&mut provider, key);
856    }
857    for send in settled.sends {
858        if let Value::Object(send) = send {
859            deep_merge(&mut body, send);
860        }
861    }
862    deep_merge(&mut body, provider);
863    deep_merge(&mut body, raw);
864    let mut body = FinalBody {
865        fields: body,
866        last: Vec::new(),
867    };
868    for rewrite in rewrites {
869        apply(target, rewrite, &mut body, &handles)?;
870    }
871    Ok(body)
872}
873
874/// Apply one post-merge write to `body`.
875fn apply(
876    target: &dyn ReplayTarget,
877    rewrite: &Rewrite,
878    body: &mut FinalBody,
879    handles: &[String],
880) -> Result<(), EncodeError> {
881    let FinalBody { fields: body, last } = body;
882    match rewrite {
883        Rewrite::OutputCapRename => {
884            // Read on the full id: a `vendor/model` id (a LiteLLM-style
885            // proxy behind `OPENAI_BASE_URL`) keeps `max_tokens`.
886            let reasoning = body
887                .get("model")
888                .and_then(Value::as_str)
889                .is_some_and(|model| {
890                    let facts = target
891                        .facts()
892                        .unwrap_or_else(|| crate::catalog::ModelFacts::builtin());
893                    !model.contains('/')
894                        && crate::providers::openai::options::reasons(facts, model) == Some(true)
895                });
896            if reasoning && let Some(max_tokens) = remove_preserving_order(body, "max_tokens") {
897                body.entry("max_completion_tokens").or_insert(max_tokens);
898            }
899        }
900        Rewrite::DropUnboundThinking => {
901            let adaptive = body.get("thinking").is_none_or(|thinking| {
902                thinking.get("type").and_then(Value::as_str) == Some("adaptive")
903            });
904            if adaptive {
905                crate::providers::anthropic::completion::drop_unbound_thinking(body);
906            }
907        }
908        Rewrite::AnthropicVersionInBody(version) => {
909            remove_preserving_order(body, "model");
910            body.insert("anthropic_version".to_owned(), version.as_str().into());
911        }
912        Rewrite::ToolChoiceNeedsTools => {
913            let has_tools = body
914                .get("tools")
915                .and_then(Value::as_array)
916                .is_some_and(|tools| !tools.is_empty());
917            if has_tools {
918                body.entry("tool_choice")
919                    .or_insert_with(|| serde_json::json!({ "type": "auto" }));
920            } else {
921                remove_preserving_order(body, "tool_choice");
922            }
923        }
924        Rewrite::Stream(stream) => {
925            body.insert("stream".to_owned(), Value::Bool(*stream));
926        }
927        Rewrite::NoStream => {
928            remove_preserving_order(body, "stream");
929        }
930        Rewrite::NoBackground => {
931            remove_preserving_order(body, "background");
932        }
933        Rewrite::StreamUsage => {
934            if let Some(options) = body
935                .entry("stream_options")
936                .or_insert_with(|| Value::Object(Map::new()))
937                .as_object_mut()
938            {
939                options.entry("include_usage").or_insert(Value::Bool(true));
940            }
941        }
942        Rewrite::ReasoningCiphertext(always) => {
943            let wanted = *always
944                || body.get("reasoning").is_some()
945                || body.get("store") == Some(&Value::Bool(false));
946            if wanted {
947                crate::providers::openai::responses_api::include_ciphertext(body);
948            }
949        }
950        Rewrite::CodexStore => {
951            body.insert("store".to_owned(), Value::Bool(false));
952        }
953        Rewrite::ChatDialect(kind) => {
954            crate::providers::openai::wire::chat::rewrite_body(*kind, body)?;
955        }
956        Rewrite::GeminiCachedContent(handle) => {
957            for name in handles.iter().chain(handle) {
958                crate::providers::gemini::completion::with_cached_content(body, name)?;
959            }
960        }
961        Rewrite::MoveLast(key) => {
962            if body.contains_key(*key) {
963                last.retain(|previous| previous != key);
964                last.push(*key);
965            }
966        }
967    }
968    Ok(())
969}