Skip to main content

switchyard_translation/
request_edit.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Request edits update normalized content and preserved provider bodies together.
5
6use serde_json::{Map, Value, json};
7use switchyard_protocol::{ContentBlock, InstructionBlock, LlmRequest, Message, Role, WireFormat};
8
9/// This function appends temporary guidance and updates preserved provider bodies.
10/// It removes bodies it cannot edit so encoding rebuilds them from the updated request.
11pub fn append_note(request: &mut LlmRequest, note: &str) {
12    // A separate message preserves the caller's final message boundary for OpenAI caching.
13    request.messages.push(Message::text(Role::User, note));
14    request.temporary_note_count += 1;
15    request.preservation.requests.retain(|format, body| {
16        let Some(body) = body.as_object_mut() else {
17            return false;
18        };
19        let (key, content_type) = match format.as_str() {
20            "openai_chat" | "anthropic_messages" => ("messages", "text"),
21            "openai_responses" => ("input", "input_text"),
22            _ => return false,
23        };
24        // Responses also accepts a string as a single user input.
25        if key == "input"
26            && let Some(Value::String(text)) = body.get_mut(key)
27        {
28            let original = std::mem::take(text);
29            body.insert(key.into(), json!([{"role":"user", "content":original}]));
30        }
31        if key == "input" && body.get(key).is_none_or(Value::is_null) {
32            body.insert(key.into(), Value::Array(Vec::new()));
33        }
34        let Some(messages) = body.get_mut(key).and_then(Value::as_array_mut) else {
35            return false;
36        };
37        if format.as_str() == WireFormat::AnthropicMessages.as_str()
38            && let Some(last) = messages.last_mut()
39            && last.get("role").and_then(Value::as_str) == Some("user")
40        {
41            // Anthropic guidance follows existing content, including any tool result.
42            match last.get_mut("content") {
43                Some(Value::String(text)) => {
44                    let original = std::mem::take(text);
45                    last["content"] = json!([
46                        {"type":"text", "text":original},
47                        {"type":"text", "text":note}
48                    ]);
49                }
50                Some(Value::Array(blocks)) => blocks.push(json!({"type":"text", "text":note})),
51                _ => return false,
52            }
53        } else {
54            messages.push(json!({"role":"user", "content":[{"type":content_type,"text":note}]}));
55        }
56        true
57    });
58}
59
60/// This function prepends a system prompt and updates supported preserved provider bodies.
61/// It removes bodies it cannot edit so encoding rebuilds them from the updated request.
62pub fn prepend_system_prompt(request: &mut LlmRequest, prompt: &str) {
63    // An empty instruction preserves an explicit empty field until a prompt replaces it.
64    // Removing it here prevents reconstruction from adding an extra separator after the prompt.
65    if matches!(request.instructions.as_slice(), [InstructionBlock { content, .. }]
66        if matches!(content.as_slice(), [ContentBlock::Text { text, .. }] if text.is_empty()))
67    {
68        request.instructions.clear();
69    }
70    request.instructions.insert(
71        0,
72        InstructionBlock {
73            role: Role::System,
74            content: vec![ContentBlock::Text {
75                responses: None,
76                text: prompt.to_string(),
77            }],
78        },
79    );
80    request.preservation.requests.retain(|format, body| {
81        let Some(body) = body.as_object_mut() else {
82            return false;
83        };
84        match format.as_str() {
85            "openai_chat" => prepend_openai_chat_system(body, prompt),
86            "openai_responses" => prepend_text_field(body, "instructions", prompt),
87            "anthropic_messages" => {
88                body.remove("switchyard_anthropic_request");
89                prepend_anthropic_system(body, prompt)
90            }
91            _ => false,
92        }
93    });
94}
95
96// This helper inserts a system message while keeping the existing Chat messages intact.
97fn prepend_openai_chat_system(body: &mut Map<String, Value>, prompt: &str) -> bool {
98    let message = json!({"role": "system", "content": prompt});
99    match body.get_mut("messages") {
100        None | Some(Value::Null) => {
101            body.insert("messages".to_string(), Value::Array(vec![message]));
102        }
103        Some(Value::Array(messages)) => messages.insert(0, message),
104        Some(_) => return false,
105    }
106    true
107}
108
109// Preserved Responses instructions and Anthropic system prompts can be strings, missing, or null.
110fn prepend_text_field(body: &mut Map<String, Value>, key: &str, prompt: &str) -> bool {
111    match body.get_mut(key) {
112        None | Some(Value::Null) => {
113            body.insert(key.to_string(), Value::String(prompt.to_string()));
114        }
115        Some(Value::String(text)) if text.is_empty() => {
116            *text = prompt.to_string();
117        }
118        Some(Value::String(text)) => text.insert_str(0, &format!("{prompt}\n\n")),
119        Some(_) => return false,
120    }
121    true
122}
123
124// This helper retains existing Anthropic system blocks when it prepends a prompt.
125fn prepend_anthropic_system(body: &mut Map<String, Value>, prompt: &str) -> bool {
126    if let Some(Value::Array(blocks)) = body.get_mut("system") {
127        blocks.insert(0, json!({"type":"text","text":prompt}));
128        true
129    } else {
130        prepend_text_field(body, "system", prompt)
131    }
132}