From 261fc7ebd37f1c08a0d15ed1e1a5ae176eae78ae Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Wed, 24 Jun 2026 15:51:50 -0700 Subject: [PATCH 1/3] refactor: codec-owned provider-surface detection via a built-in registry Move provider-surface detection out of the hard-coded if/else chains in codec::resolve into codec-owned SurfaceDescriptors iterated through a small built-in registry. resolve.rs is now provider-agnostic (no field-name literals); each built-in codec owns its detection and decode logic in a SURFACE_DESCRIPTOR const. Add detect_request_surface_with_hint(body, Option<&str>); detect_request_surface delegates to it with None for exact parity. A recognized provider hint can upgrade the ambiguous messages-only shape (the Anthropic detector claims it for provider "anthropic"), while registry priority order keeps it from overriding a strong input/instructions/system signal. The hint is not wired into adaptive, so behavior is preserved and all existing codec::resolve tests pass unchanged. Relates to RELAY-362 Signed-off-by: Zhongxuan Wang --- crates/core/src/codec/anthropic.rs | 20 +++++ crates/core/src/codec/openai_chat.rs | 13 +++ crates/core/src/codec/openai_responses.rs | 16 ++++ crates/core/src/codec/resolve.rs | 87 +++++++++++-------- crates/core/tests/unit/codec/resolve_tests.rs | 81 +++++++++++++++++ 5 files changed, 182 insertions(+), 35 deletions(-) diff --git a/crates/core/src/codec/anthropic.rs b/crates/core/src/codec/anthropic.rs index b7f8b84b7..e3596fe37 100644 --- a/crates/core/src/codec/anthropic.rs +++ b/crates/core/src/codec/anthropic.rs @@ -26,6 +26,7 @@ use super::request::{ AnnotatedLlmRequest, FunctionDefinition, GenerationParams, Message, MessageContent, ToolChoice, ToolChoiceFunction, ToolChoiceFunctionName, ToolDefinition, }; +use super::resolve::{ProviderSurface, SurfaceDescriptor}; use super::response::{ AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage, estimate_cost_for_provider, infer_model_provider, provider_reported_cost, @@ -39,6 +40,25 @@ use super::traits::{LlmCodec, LlmResponseCodec}; /// Built-in codec for the Anthropic Messages API. pub struct AnthropicMessagesCodec; +// --------------------------------------------------------------------------- +// Built-in surface descriptor (codec-owned detection, registered in resolve) +// --------------------------------------------------------------------------- + +pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor { + surface: ProviderSurface::AnthropicMessages, + detect_request: |obj, hint| { + // A system-less Anthropic request is shape-identical to OpenAI Chat; + // the "anthropic" hint disambiguates it. + obj.contains_key("system") || (hint == Some("anthropic") && obj.contains_key("messages")) + }, + detect_response: |obj| { + obj.get("type").and_then(Json::as_str) == Some("message") + && obj.get("content").is_some_and(Json::is_array) + }, + decode_request: |request| AnthropicMessagesCodec.decode(request), + decode_response: |raw| AnthropicMessagesCodec.decode_response(raw), +}; + // --------------------------------------------------------------------------- // Private intermediate serde structs for response decode // --------------------------------------------------------------------------- diff --git a/crates/core/src/codec/openai_chat.rs b/crates/core/src/codec/openai_chat.rs index 5bf992d54..560026d7c 100644 --- a/crates/core/src/codec/openai_chat.rs +++ b/crates/core/src/codec/openai_chat.rs @@ -13,6 +13,7 @@ use crate::error::{FlowError, Result}; use crate::json::Json; use super::request::{AnnotatedLlmRequest, GenerationParams, Message, ToolChoice, ToolDefinition}; +use super::resolve::{ProviderSurface, SurfaceDescriptor}; use super::response::{ AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage, estimate_cost_for_provider, infer_model_provider, provider_reported_cost, @@ -26,6 +27,18 @@ use super::traits::{LlmCodec, LlmResponseCodec}; /// Built-in codec for the OpenAI Chat Completions API. pub struct OpenAIChatCodec; +// --------------------------------------------------------------------------- +// Built-in surface descriptor (codec-owned detection, registered in resolve) +// --------------------------------------------------------------------------- + +pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor { + surface: ProviderSurface::OpenAIChat, + detect_request: |obj, _| obj.contains_key("messages"), + detect_response: |obj| obj.get("choices").is_some_and(Json::is_array), + decode_request: |request| OpenAIChatCodec.decode(request), + decode_response: |raw| OpenAIChatCodec.decode_response(raw), +}; + // --------------------------------------------------------------------------- // Private intermediate serde structs for response decode // --------------------------------------------------------------------------- diff --git a/crates/core/src/codec/openai_responses.rs b/crates/core/src/codec/openai_responses.rs index 438aa8ac3..e1dc52560 100644 --- a/crates/core/src/codec/openai_responses.rs +++ b/crates/core/src/codec/openai_responses.rs @@ -25,6 +25,7 @@ use super::request::{ AnnotatedLlmRequest, GenerationParams, Message, MessageContent, ToolChoice, ToolChoiceFunction, ToolChoiceFunctionName, ToolDefinition, }; +use super::resolve::{ProviderSurface, SurfaceDescriptor}; use super::response::{ AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage, estimate_cost_for_provider, infer_model_provider, provider_reported_cost, @@ -38,6 +39,21 @@ use super::traits::{LlmCodec, LlmResponseCodec}; /// Built-in codec for the OpenAI Responses API. pub struct OpenAIResponsesCodec; +// --------------------------------------------------------------------------- +// Built-in surface descriptor (codec-owned detection, registered in resolve) +// --------------------------------------------------------------------------- + +pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor { + surface: ProviderSurface::OpenAIResponses, + detect_request: |obj, _| obj.contains_key("input") || obj.contains_key("instructions"), + detect_response: |obj| { + obj.get("output").is_some_and(Json::is_array) + || obj.get("output_text").is_some_and(Json::is_string) + }, + decode_request: |request| OpenAIResponsesCodec.decode(request), + decode_response: |raw| OpenAIResponsesCodec.decode_response(raw), +}; + // --------------------------------------------------------------------------- // Private intermediate serde structs for response decode // --------------------------------------------------------------------------- diff --git a/crates/core/src/codec/resolve.rs b/crates/core/src/codec/resolve.rs index 2eeaec6f9..f42be98db 100644 --- a/crates/core/src/codec/resolve.rs +++ b/crates/core/src/codec/resolve.rs @@ -6,14 +6,12 @@ //! is present. use crate::api::llm::LlmRequest; +use crate::error::Result; use crate::json::Json; -use super::anthropic::AnthropicMessagesCodec; -use super::openai_chat::OpenAIChatCodec; -use super::openai_responses::OpenAIResponsesCodec; use super::request::AnnotatedLlmRequest; use super::response::AnnotatedLlmResponse; -use super::traits::{LlmCodec, LlmResponseCodec}; +use super::{anthropic, openai_chat, openai_responses}; /// A built-in provider request/response surface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +24,28 @@ pub enum ProviderSurface { AnthropicMessages, } +/// Request shape detector; the optional `&str` is a provider hint a codec may use +/// to claim an otherwise-ambiguous shape. +type RequestDetector = fn(&serde_json::Map, Option<&str>) -> bool; +type ResponseDetector = fn(&serde_json::Map) -> bool; + +pub(crate) struct SurfaceDescriptor { + pub(crate) surface: ProviderSurface, + pub(crate) detect_request: RequestDetector, + pub(crate) detect_response: ResponseDetector, + pub(crate) decode_request: fn(&LlmRequest) -> Result, + pub(crate) decode_response: fn(&Json) -> Result, +} + +/// Built-in surfaces in request-detection priority order (first match wins): +/// Responses > Anthropic > Chat. The order is authoritative — a hint-aware +/// detector must stay after any stronger-signal surface it could shadow. +static REGISTRY: &[SurfaceDescriptor] = &[ + openai_responses::SURFACE_DESCRIPTOR, + anthropic::SURFACE_DESCRIPTOR, + openai_chat::SURFACE_DESCRIPTOR, +]; + /// Detect the request surface from a raw request body by top-level key. /// /// Priority: OpenAI Responses (`input`/`instructions`) > Anthropic Messages @@ -35,16 +55,22 @@ pub enum ProviderSurface { /// and classifies as `OpenAIChat`. #[must_use] pub fn detect_request_surface(body: &Json) -> Option { + detect_request_surface_with_hint(body, None) +} + +/// Like [`detect_request_surface`], but a recognized `provider_hint` resolves the +/// one ambiguous shape (an Anthropic request without a top-level `system`, +/// otherwise read as OpenAI Chat). A `None` or unrecognized hint is shape-only. +#[must_use] +pub fn detect_request_surface_with_hint( + body: &Json, + provider_hint: Option<&str>, +) -> Option { let obj = body.as_object()?; - if obj.contains_key("input") || obj.contains_key("instructions") { - Some(ProviderSurface::OpenAIResponses) - } else if obj.contains_key("system") { - Some(ProviderSurface::AnthropicMessages) - } else if obj.contains_key("messages") { - Some(ProviderSurface::OpenAIChat) - } else { - None - } + REGISTRY + .iter() + .find(|d| (d.detect_request)(obj, provider_hint)) + .map(|d| d.surface) } /// Detect the response surface from a raw provider response, classifying only @@ -53,16 +79,9 @@ pub fn detect_request_surface(body: &Json) -> Option { #[must_use] pub fn detect_response_surface(raw: &Json) -> Option { let obj = raw.as_object()?; - let is_chat = obj.get("choices").is_some_and(Json::is_array); - let is_responses = obj.get("output").is_some_and(Json::is_array) - || obj.get("output_text").is_some_and(Json::is_string); - let is_anthropic = obj.get("type").and_then(Json::as_str) == Some("message") - && obj.get("content").is_some_and(Json::is_array); - - match (is_chat, is_responses, is_anthropic) { - (true, false, false) => Some(ProviderSurface::OpenAIChat), - (false, true, false) => Some(ProviderSurface::OpenAIResponses), - (false, false, true) => Some(ProviderSurface::AnthropicMessages), + let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj)); + match (matches.next(), matches.next()) { + (Some(descriptor), None) => Some(descriptor.surface), _ => None, } } @@ -70,23 +89,21 @@ pub fn detect_response_surface(raw: &Json) -> Option { /// Best-effort decode of a raw request into [`AnnotatedLlmRequest`] (fail-open). #[must_use] pub fn normalize_request(request: &LlmRequest) -> Option { - match detect_request_surface(&request.content)? { - ProviderSurface::OpenAIChat => OpenAIChatCodec.decode(request), - ProviderSurface::OpenAIResponses => OpenAIResponsesCodec.decode(request), - ProviderSurface::AnthropicMessages => AnthropicMessagesCodec.decode(request), - } - .ok() + let obj = request.content.as_object()?; + let descriptor = REGISTRY.iter().find(|d| (d.detect_request)(obj, None))?; + (descriptor.decode_request)(request).ok() } /// Best-effort decode of a raw response into [`AnnotatedLlmResponse`] (fail-open). #[must_use] pub fn normalize_response(raw: &Json) -> Option { - match detect_response_surface(raw)? { - ProviderSurface::OpenAIChat => OpenAIChatCodec.decode_response(raw), - ProviderSurface::OpenAIResponses => OpenAIResponsesCodec.decode_response(raw), - ProviderSurface::AnthropicMessages => AnthropicMessagesCodec.decode_response(raw), - } - .ok() + let obj = raw.as_object()?; + let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj)); + let descriptor = match (matches.next(), matches.next()) { + (Some(descriptor), None) => descriptor, + _ => return None, + }; + (descriptor.decode_response)(raw).ok() } #[cfg(test)] diff --git a/crates/core/tests/unit/codec/resolve_tests.rs b/crates/core/tests/unit/codec/resolve_tests.rs index c307f6bd7..8109f9a63 100644 --- a/crates/core/tests/unit/codec/resolve_tests.rs +++ b/crates/core/tests/unit/codec/resolve_tests.rs @@ -218,3 +218,84 @@ fn normalize_request_decodes_detected_anthropic() { fn normalize_request_none_for_unknown_shape() { assert!(normalize_request(&req(json!({"foo": 1}))).is_none()); } + +// --------------------------------------------------------------------------- +// detect_request_surface_with_hint (provider hint upgrades the ambiguous shape) +// --------------------------------------------------------------------------- + +#[test] +fn hint_none_matches_plain_detection() { + for body in [ + json!({"input": []}), + json!({"instructions": "x"}), + json!({"system": "x", "messages": []}), + json!({"messages": []}), + json!({"input": [], "system": "x", "messages": []}), + json!({}), + json!({"foo": 1}), + json!([1, 2, 3]), + ] { + assert_eq!( + detect_request_surface_with_hint(&body, None), + detect_request_surface(&body), + "hint=None must match plain detection for {body:?}", + ); + } +} + +#[test] +fn hint_anthropic_upgrades_system_less_messages() { + assert_eq!( + detect_request_surface(&json!({"messages": []})), + Some(ProviderSurface::OpenAIChat) + ); + assert_eq!( + detect_request_surface_with_hint(&json!({"messages": []}), Some("anthropic")), + Some(ProviderSurface::AnthropicMessages) + ); +} + +#[test] +fn hint_other_or_unknown_provider_stays_chat() { + for hint in [Some("openai"), Some("passthrough"), Some("gemini"), None] { + assert_eq!( + detect_request_surface_with_hint(&json!({"messages": []}), hint), + Some(ProviderSurface::OpenAIChat), + "messages-only with hint {hint:?} should stay OpenAIChat", + ); + } +} + +#[test] +fn hint_never_overrides_strong_signals() { + assert_eq!( + detect_request_surface_with_hint(&json!({"input": [], "messages": []}), Some("anthropic")), + Some(ProviderSurface::OpenAIResponses) + ); + assert_eq!( + detect_request_surface_with_hint( + &json!({"instructions": "x", "messages": []}), + Some("anthropic") + ), + Some(ProviderSurface::OpenAIResponses) + ); + assert_eq!( + detect_request_surface_with_hint( + &json!({"system": "x", "messages": []}), + Some("anthropic") + ), + Some(ProviderSurface::AnthropicMessages) + ); +} + +#[test] +fn hint_does_not_classify_non_object_or_keyless() { + assert_eq!( + detect_request_surface_with_hint(&json!({}), Some("anthropic")), + None + ); + assert_eq!( + detect_request_surface_with_hint(&json!([1, 2]), Some("anthropic")), + None + ); +} From ac4eac2862d9d7f5d4229143918abed20757df9f Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Wed, 24 Jun 2026 20:59:25 -0700 Subject: [PATCH 2/3] refactor: share codec response-surface classification rule Extract the "exactly one matching response descriptor" selection into a private detect_response_descriptor helper so detect_response_surface and normalize_response use one source of truth instead of independently re-implementing the matches.next()/next() rule. This prevents detection and normalization from silently diverging if the rule is later changed. Add a normalize_response regression assertion for the ambiguous choices+output shape to guard the shared classifier. Relates to RELAY-362 Signed-off-by: Zhongxuan Wang --- crates/core/src/codec/resolve.rs | 29 +++++++++++-------- crates/core/tests/unit/codec/resolve_tests.rs | 4 +++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/core/src/codec/resolve.rs b/crates/core/src/codec/resolve.rs index f42be98db..f9c888501 100644 --- a/crates/core/src/codec/resolve.rs +++ b/crates/core/src/codec/resolve.rs @@ -73,17 +73,27 @@ pub fn detect_request_surface_with_hint( .map(|d| d.surface) } +/// Classify a response object to exactly one built-in surface descriptor: the +/// single source of truth shared by [`detect_response_surface`] and +/// [`normalize_response`]. Zero or multiple matches yield `None` (the built-in +/// codecs accept minimal objects, so decode success alone is not a reliable +/// classifier). +fn detect_response_descriptor( + obj: &serde_json::Map, +) -> Option<&'static SurfaceDescriptor> { + let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj)); + match (matches.next(), matches.next()) { + (Some(descriptor), None) => Some(descriptor), + _ => None, + } +} + /// Detect the response surface from a raw provider response, classifying only /// when exactly one built-in shape matches (the built-in codecs accept minimal /// objects, so decode success alone is not a reliable classifier). #[must_use] pub fn detect_response_surface(raw: &Json) -> Option { - let obj = raw.as_object()?; - let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj)); - match (matches.next(), matches.next()) { - (Some(descriptor), None) => Some(descriptor.surface), - _ => None, - } + detect_response_descriptor(raw.as_object()?).map(|d| d.surface) } /// Best-effort decode of a raw request into [`AnnotatedLlmRequest`] (fail-open). @@ -97,12 +107,7 @@ pub fn normalize_request(request: &LlmRequest) -> Option { /// Best-effort decode of a raw response into [`AnnotatedLlmResponse`] (fail-open). #[must_use] pub fn normalize_response(raw: &Json) -> Option { - let obj = raw.as_object()?; - let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj)); - let descriptor = match (matches.next(), matches.next()) { - (Some(descriptor), None) => descriptor, - _ => return None, - }; + let descriptor = detect_response_descriptor(raw.as_object()?)?; (descriptor.decode_response)(raw).ok() } diff --git a/crates/core/tests/unit/codec/resolve_tests.rs b/crates/core/tests/unit/codec/resolve_tests.rs index 8109f9a63..3f1547c3b 100644 --- a/crates/core/tests/unit/codec/resolve_tests.rs +++ b/crates/core/tests/unit/codec/resolve_tests.rs @@ -186,6 +186,10 @@ fn normalize_response_none_for_unrecognized_shape() { assert!(normalize_response(&json!({"foo": 1})).is_none()); // Ambiguous/empty objects do not classify, so they do not decode. assert!(normalize_response(&json!({})).is_none()); + // Multiple matching shapes are ambiguous: detection and normalization share + // one exactly-one rule, so normalization must also decline (guards the + // shared classifier against divergence). + assert!(normalize_response(&json!({"choices": [], "output": []})).is_none()); } // --------------------------------------------------------------------------- From 7bb3cb6789a72be43ad4b47e487d9eff020e8980 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Thu, 25 Jun 2026 10:25:29 -0700 Subject: [PATCH 3/3] test: cover OpenAI Responses request normalization; clarify hint rustdoc Add a normalize_request test for the OpenAI Responses surface so the registry decode_request closure is exercised, closing the codecov/patch gap on this PR's new source lines. Also name the accepted "anthropic" provider hint explicitly in the detect_request_surface_with_hint rustdoc, per review feedback. Signed-off-by: Zhongxuan Wang --- crates/core/src/codec/resolve.rs | 4 +++- crates/core/tests/unit/codec/resolve_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/core/src/codec/resolve.rs b/crates/core/src/codec/resolve.rs index f9c888501..fca3755f8 100644 --- a/crates/core/src/codec/resolve.rs +++ b/crates/core/src/codec/resolve.rs @@ -60,7 +60,9 @@ pub fn detect_request_surface(body: &Json) -> Option { /// Like [`detect_request_surface`], but a recognized `provider_hint` resolves the /// one ambiguous shape (an Anthropic request without a top-level `system`, -/// otherwise read as OpenAI Chat). A `None` or unrecognized hint is shape-only. +/// otherwise read as OpenAI Chat). Today, `"anthropic"` is the only hint that +/// changes detection; `None` or any other value is ignored and detection stays +/// shape-only. #[must_use] pub fn detect_request_surface_with_hint( body: &Json, diff --git a/crates/core/tests/unit/codec/resolve_tests.rs b/crates/core/tests/unit/codec/resolve_tests.rs index 3f1547c3b..e9c552b7a 100644 --- a/crates/core/tests/unit/codec/resolve_tests.rs +++ b/crates/core/tests/unit/codec/resolve_tests.rs @@ -218,6 +218,17 @@ fn normalize_request_decodes_detected_anthropic() { assert!(!decoded.messages.is_empty()); } +#[test] +fn normalize_request_decodes_detected_responses() { + // `input` selects the OpenAI Responses surface (priority over chat/anthropic). + let request = req(json!({ + "model": "gpt-4o", + "input": "Hello, world!" + })); + let decoded = normalize_request(&request).expect("responses request decodes"); + assert!(!decoded.messages.is_empty()); +} + #[test] fn normalize_request_none_for_unknown_shape() { assert!(normalize_request(&req(json!({"foo": 1}))).is_none());