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