Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions crates/libsy/src/algorithms/util/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ mod tests {
Ok(request)
}

/// A request that already carries an exact inbound body for same-format replay.
fn request_with_preserved_body() -> Request {
let mut request = Request::default();
request.llm_request.preservation.requests.insert(
"openai_chat".into(),
serde_json::json!({
"model": "weak-model",
"messages": [{"role": "user", "content": "hi"}],
}),
);
// A codec seals once decoding is done; without that the body reads as stale.
request.llm_request.seal_preservation();
request
}

fn prompts() -> TargetPrompts {
TargetPrompts::default()
.with("strong", STRONG_PROMPT)
Expand Down Expand Up @@ -304,4 +319,65 @@ mod tests {
assert!(instructions(&request).is_empty());
Ok(())
}

/// Both tier prompts must survive a same-format hop — capable and efficient
/// alike, and whatever else an algorithm wires in. The codec replays the
/// preserved inbound body verbatim when it is still current, so adding an
/// instruction has to stop it being current.
#[tokio::test]
async fn any_tier_prompt_invalidates_exact_replay() -> Result<()> {
let processor = SystemPromptProcessor::new(prompts());
for (target, expected) in [("strong", STRONG_PROMPT), ("weak", WEAK_PROMPT)] {
let mut request = request_with_preserved_body();
processor
.process(
&mut (),
Event::Decision {
request: &mut request,
decision: &RoutedTo(target),
},
)
.await?;

assert_eq!(instructions(&request), vec![expected]);
assert!(
!request.llm_request.preserved_request_is_current(),
"{target}: preserved inbound body would replay without the tier prompt"
);
}
Ok(())
}

/// Same contract for the one-off note: it is added to the conversation, so a
/// replayed body would drop it too.
#[test]
fn note_drops_preserved_body() {
let mut request = request_with_preserved_body();
append_note(&mut request, NOTE);
assert!(
!request.llm_request.preserved_request_is_current(),
"preserved inbound body would replay without the note"
);
}

/// A target with no configured prompt is routed untouched, so exact replay stays.
#[tokio::test]
async fn unprompted_target_keeps_preserved_body() -> Result<()> {
let processor = SystemPromptProcessor::new(prompts());
let mut request = request_with_preserved_body();
processor
.process(
&mut (),
Event::Decision {
request: &mut request,
decision: &RoutedTo("unconfigured"),
},
)
.await?;
assert!(
request.llm_request.preserved_request_is_current(),
"an untouched request keeps exact replay"
);
Ok(())
}
}
58 changes: 55 additions & 3 deletions crates/protocol/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,16 +284,25 @@ pub struct ProviderExtensions {
/// Exact source payloads retained for lossless same-format round trips.
///
/// Translation's default preservation policy prefers a stored same-format body
/// over reconstructing one from normalized fields. A caller that mutates the IR
/// must clear the corresponding entry or use a policy with preservation disabled
/// when those mutations must be encoded.
/// over reconstructing one from normalized fields, which is what makes a
/// same-format hop lossless.
///
/// A stored body is only a faithful stand-in while the IR still matches it.
/// [`LlmRequest::seal_preservation`] records what the IR looked like when the
/// body was captured, and [`LlmRequest::preserved_request_is_current`] reports
/// whether it still does. Callers that mutate the IR — adding a system prompt,
/// appending a handoff note — need do nothing: the seal stops matching and
/// codecs re-encode from normalized fields on their own.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PreservationMetadata {
/// Original request bodies keyed by source format.
pub requests: BTreeMap<FormatId, Value>,
/// Original response bodies keyed by source format.
pub responses: BTreeMap<FormatId, Value>,
/// Fingerprint of the request IR at the moment the bodies above were
/// captured. `None` means unsealed, which reads as "not current".
pub request_seal: Option<u64>,
}

/// Normalized request representation shared by Switchyard components.
Expand Down Expand Up @@ -326,6 +335,49 @@ pub struct LlmRequest {
pub preservation: PreservationMetadata,
}

impl LlmRequest {
/// Records the current shape of this request against its preserved bodies.
///
/// Codecs call this once decoding is complete. Until it is called the
/// preserved bodies are treated as stale, so an un-sealed request always
/// re-encodes from normalized fields.
pub fn seal_preservation(&mut self) {
self.preservation.request_seal = None;
self.preservation.request_seal = Some(self.shape_fingerprint());
}

/// Whether the preserved bodies still describe this request.
///
/// Returns `false` once anything has changed since [`Self::seal_preservation`]
/// — a routing algorithm inserting a tier system prompt, appending a handoff
/// note, rewriting the model — which is what stops a same-format hop from
/// replaying a body that predates the change.
pub fn preserved_request_is_current(&self) -> bool {
self.preservation.request_seal == Some(self.shape_fingerprint())
}

/// Hashes everything except the seal itself, so sealing is idempotent.
///
/// `model` is deliberately excluded. Routing rewrites it on every hop and the
/// client stamps the resolved name onto the encoded body afterwards, so a
/// replayed body is never wrong about the model — unlike a prompt or a note,
/// which only exist in the IR.
fn shape_fingerprint(&self) -> u64 {
use std::hash::{Hash, Hasher};

let mut unsealed = self.clone();
unsealed.preservation.request_seal = None;
unsealed.model = None;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// Serialization gives a total order over the IR without requiring `Hash`
// on every nested provider value; `Value` maps are ordered.
serde_json::to_string(&unsealed)
.unwrap_or_default()
.hash(&mut hasher);
hasher.finish()
}
}

/// Normalized token usage counts.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Usage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ impl FormatCodec for AnthropicMessagesCodec {
],
);

// Record the shape the preserved body describes. Anything that mutates the
// IR after this point invalidates exact replay, so a tier prompt or a
// handoff note cannot be silently dropped on a same-format hop.
request.seal_preservation();
Ok(DecodedRequest {
request,
diagnostics,
Expand All @@ -161,8 +165,7 @@ impl FormatCodec for AnthropicMessagesCodec {
request: &LlmRequest,
policy: &TranslationPolicy,
) -> Result<EncodedRequest> {
if let Some(body) =
exact_preserved_request(&request.preservation, WireFormat::AnthropicMessages, policy)
if let Some(body) = exact_preserved_request(request, WireFormat::AnthropicMessages, policy)
{
return Ok(EncodedRequest {
body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ impl FormatCodec for OpenAiChatCodec {
],
);

// Record the shape the preserved body describes. Anything that mutates the
// IR after this point invalidates exact replay, so a tier prompt or a
// handoff note cannot be silently dropped on a same-format hop.
request.seal_preservation();
Ok(DecodedRequest {
request,
diagnostics,
Expand All @@ -176,9 +180,7 @@ impl FormatCodec for OpenAiChatCodec {
request: &LlmRequest,
policy: &TranslationPolicy,
) -> Result<EncodedRequest> {
if let Some(body) =
exact_preserved_request(&request.preservation, WireFormat::OpenAiChat, policy)
{
if let Some(body) = exact_preserved_request(request, WireFormat::OpenAiChat, policy) {
return Ok(EncodedRequest {
body,
diagnostics: Vec::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ impl FormatCodec for OpenAiResponsesCodec {
"stream",
],
);
// Record the shape the preserved body describes. Anything that mutates the
// IR after this point invalidates exact replay, so a tier prompt or a
// handoff note cannot be silently dropped on a same-format hop.
request.seal_preservation();
Ok(DecodedRequest {
request,
diagnostics,
Expand All @@ -118,9 +122,7 @@ impl FormatCodec for OpenAiResponsesCodec {
request: &LlmRequest,
_policy: &TranslationPolicy,
) -> Result<EncodedRequest> {
if let Some(body) =
exact_preserved_request(&request.preservation, WireFormat::OpenAiResponses, _policy)
{
if let Some(body) = exact_preserved_request(request, WireFormat::OpenAiResponses, _policy) {
return Ok(EncodedRequest {
body,
diagnostics: Vec::new(),
Expand Down
11 changes: 9 additions & 2 deletions crates/switchyard-translation/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,21 @@ pub fn capture_response_preservation(
}

/// Returns an exact preserved request for the target format when available.
///
/// Replay is refused once the request IR has moved on from the body — a routing
/// algorithm having added a tier system prompt or a handoff note, say. Encoding
/// then falls through to normalized fields so the addition reaches the wire.
pub fn exact_preserved_request(
preservation: &PreservationMetadata,
request: &LlmRequest,
format: impl Into<FormatId>,
policy: &TranslationPolicy,
) -> Option<Value> {
if !request.preserved_request_is_current() {
return None;
}
let format = format.into();
(policy.preservation != PreservationPolicy::Disabled)
.then(|| preservation.requests.get(&format).cloned())
.then(|| request.preservation.requests.get(&format).cloned())
.flatten()
}

Expand Down
32 changes: 17 additions & 15 deletions crates/switchyard-translation/tests/extension_points.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,23 @@ impl FormatCodec for MinimalCustomCodec {
body: &Value,
policy: &TranslationPolicy,
) -> switchyard_translation::Result<DecodedRequest> {
Ok(DecodedRequest {
request: LlmRequest {
model: body
.get("model")
let mut request = LlmRequest {
model: body
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
messages: vec![Message::text(
Role::User,
body.get("prompt")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
messages: vec![Message::text(
Role::User,
body.get("prompt")
.and_then(Value::as_str)
.unwrap_or_default(),
)],
preservation: capture_request_preservation(self.format(), body, policy),
..LlmRequest::default()
},
.unwrap_or_default(),
)],
preservation: capture_request_preservation(self.format(), body, policy),
..LlmRequest::default()
};
request.seal_preservation();
Ok(DecodedRequest {
request,
diagnostics: Vec::new(),
})
}
Expand All @@ -178,7 +180,7 @@ impl FormatCodec for MinimalCustomCodec {
request: &LlmRequest,
policy: &TranslationPolicy,
) -> switchyard_translation::Result<EncodedRequest> {
if let Some(body) = exact_preserved_request(&request.preservation, self.format(), policy) {
if let Some(body) = exact_preserved_request(request, self.format(), policy) {
return Ok(EncodedRequest {
body,
diagnostics: Vec::new(),
Expand Down
Loading
Loading