Skip to content
Merged
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
20 changes: 20 additions & 0 deletions crates/core/src/codec/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/codec/openai_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions crates/core/src/codec/openai_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
98 changes: 61 additions & 37 deletions crates/core/src/codec/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<String, Json>, Option<&str>) -> bool;
type ResponseDetector = fn(&serde_json::Map<String, Json>) -> 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<AnnotatedLlmRequest>,
pub(crate) decode_response: fn(&Json) -> Result<AnnotatedLlmResponse>,
}

/// 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
Expand All @@ -35,15 +55,38 @@ pub enum ProviderSurface {
/// and classifies as `OpenAIChat`.
#[must_use]
pub fn detect_request_surface(body: &Json) -> Option<ProviderSurface> {
detect_request_surface_with_hint(body, None)
}

/// Like [`detect_request_surface`], but a recognized `provider_hint` resolves the
Comment thread
zhongxuanwang-nv marked this conversation as resolved.
/// one ambiguous shape (an Anthropic request without a top-level `system`,
/// 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,
provider_hint: Option<&str>,
) -> Option<ProviderSurface> {
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)
}

/// 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<String, Json>,
) -> 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,
}
}

Expand All @@ -52,41 +95,22 @@ pub fn detect_request_surface(body: &Json) -> Option<ProviderSurface> {
/// objects, so decode success alone is not a reliable classifier).
#[must_use]
pub fn detect_response_surface(raw: &Json) -> Option<ProviderSurface> {
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),
_ => None,
}
detect_response_descriptor(raw.as_object()?).map(|d| d.surface)
}

/// Best-effort decode of a raw request into [`AnnotatedLlmRequest`] (fail-open).
#[must_use]
pub fn normalize_request(request: &LlmRequest) -> Option<AnnotatedLlmRequest> {
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<AnnotatedLlmResponse> {
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 descriptor = detect_response_descriptor(raw.as_object()?)?;
(descriptor.decode_response)(raw).ok()
}

#[cfg(test)]
Expand Down
96 changes: 96 additions & 0 deletions crates/core/tests/unit/codec/resolve_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -214,7 +218,99 @@ 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());
}

// ---------------------------------------------------------------------------
// 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)
);
}
Comment thread
zhongxuanwang-nv marked this conversation as resolved.

#[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
);
}
Loading