Skip to content

Commit 4db6140

Browse files
authored
OpenResponses spec compliance (#25)
* Changes for OpenResponses spec compliance * Update specs * Add spec * Review fixes * Update samael * Downgrade samael * Review fixes * Combine shared echo utils
1 parent 133ec36 commit 4db6140

File tree

13 files changed

+14118
-6611
lines changed

13 files changed

+14118
-6611
lines changed

openapi/hadrian.openapi.json

Lines changed: 13681 additions & 6473 deletions
Large diffs are not rendered by default.

src/api_types/responses.rs

Lines changed: 233 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ where
1919
}
2020
use validator::Validate;
2121

22-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
22+
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2323
#[serde(rename_all = "snake_case")]
2424
pub enum ResponseInputImageDetail {
25+
#[default]
2526
Auto,
2627
High,
2728
Low,
@@ -268,6 +269,7 @@ pub enum ResponseInputContentItem {
268269
cache_control: Option<CacheControl>,
269270
},
270271
InputImage {
272+
#[serde(default)]
271273
detail: ResponseInputImageDetail,
272274
#[serde(skip_serializing_if = "Option::is_none")]
273275
image_url: Option<String>,
@@ -530,9 +532,9 @@ pub enum ResponsesAnnotation {
530532
pub enum OutputMessageContentItem {
531533
OutputText {
532534
text: String,
533-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
535+
#[serde(default)]
534536
annotations: Vec<ResponsesAnnotation>,
535-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
537+
#[serde(default)]
536538
logprobs: Vec<serde_json::Value>,
537539
},
538540
Refusal {
@@ -1332,6 +1334,16 @@ pub struct CreateResponsesPayload {
13321334
#[cfg_attr(feature = "utoipa", schema(value_type = Object))]
13331335
pub truncation: Option<serde_json::Value>,
13341336

1337+
/// **Hadrian Extension:** Presence penalty (-2.0 to 2.0)
1338+
#[validate(range(min = -2.0, max = 2.0))]
1339+
#[serde(skip_serializing_if = "Option::is_none")]
1340+
pub presence_penalty: Option<f64>,
1341+
1342+
/// **Hadrian Extension:** Frequency penalty (-2.0 to 2.0)
1343+
#[validate(range(min = -2.0, max = 2.0))]
1344+
#[serde(skip_serializing_if = "Option::is_none")]
1345+
pub frequency_penalty: Option<f64>,
1346+
13351347
/// Enable streaming
13361348
#[serde(default)]
13371349
pub stream: bool,
@@ -1428,53 +1440,248 @@ pub struct CreateResponsesResponse {
14281440
pub user: Option<String>,
14291441
#[serde(skip_serializing_if = "Option::is_none")]
14301442
pub output_text: Option<String>,
1431-
#[serde(skip_serializing_if = "Option::is_none")]
14321443
pub prompt_cache_key: Option<String>,
1433-
#[serde(skip_serializing_if = "Option::is_none")]
14341444
pub safety_identifier: Option<String>,
1435-
#[serde(skip_serializing_if = "Option::is_none")]
14361445
pub error: Option<ResponsesErrorField>,
1437-
#[serde(skip_serializing_if = "Option::is_none")]
14381446
pub incomplete_details: Option<ResponsesIncompleteDetails>,
14391447
#[serde(skip_serializing_if = "Option::is_none")]
14401448
pub usage: Option<ResponsesUsage>,
1441-
#[serde(skip_serializing_if = "Option::is_none")]
1449+
pub completed_at: Option<f64>,
14421450
pub max_tool_calls: Option<f64>,
1443-
#[serde(skip_serializing_if = "Option::is_none")]
14441451
pub top_logprobs: Option<f64>,
1445-
#[serde(
1446-
skip_serializing_if = "Option::is_none",
1447-
serialize_with = "serialize_as_integer"
1448-
)]
1452+
#[serde(serialize_with = "serialize_as_integer")]
14491453
pub max_output_tokens: Option<f64>,
1450-
#[serde(skip_serializing_if = "Option::is_none")]
14511454
pub temperature: Option<f64>,
1452-
#[serde(skip_serializing_if = "Option::is_none")]
14531455
pub top_p: Option<f64>,
1454-
#[serde(skip_serializing_if = "Option::is_none")]
1456+
pub presence_penalty: Option<f64>,
1457+
pub frequency_penalty: Option<f64>,
14551458
pub instructions: Option<serde_json::Value>,
14561459
#[serde(skip_serializing_if = "Option::is_none")]
14571460
pub metadata: Option<HashMap<String, String>>,
1458-
#[serde(default, skip_serializing_if = "Option::is_none")]
14591461
pub tools: Option<Vec<ResponsesToolDefinition>>,
1460-
#[serde(skip_serializing_if = "Option::is_none")]
14611462
pub tool_choice: Option<ResponsesToolChoice>,
1462-
#[serde(skip_serializing_if = "Option::is_none")]
14631463
pub parallel_tool_calls: Option<bool>,
14641464
#[serde(skip_serializing_if = "Option::is_none")]
14651465
pub prompt: Option<ResponsesPrompt>,
1466-
#[serde(skip_serializing_if = "Option::is_none")]
14671466
pub background: Option<bool>,
1468-
#[serde(skip_serializing_if = "Option::is_none")]
14691467
pub previous_response_id: Option<String>,
1470-
#[serde(skip_serializing_if = "Option::is_none")]
14711468
pub reasoning: Option<ResponsesReasoningConfigOutput>,
1472-
#[serde(skip_serializing_if = "Option::is_none")]
14731469
pub service_tier: Option<ResponsesServiceTier>,
1474-
#[serde(skip_serializing_if = "Option::is_none")]
14751470
pub store: Option<bool>,
1476-
#[serde(skip_serializing_if = "Option::is_none")]
14771471
pub truncation: Option<ResponsesTruncation>,
1478-
#[serde(skip_serializing_if = "Option::is_none")]
14791472
pub text: Option<ResponseTextConfig>,
14801473
}
1474+
1475+
impl CreateResponsesResponse {
1476+
/// Serialize to JSON with echo fields merged in per OpenAI Responses API spec.
1477+
pub fn to_json_with_echo(
1478+
&self,
1479+
echo_fields: serde_json::Map<String, serde_json::Value>,
1480+
) -> serde_json::Value {
1481+
let mut val = serde_json::to_value(self).unwrap_or_default();
1482+
if let serde_json::Value::Object(ref mut map) = val {
1483+
if self.status == Some(ResponsesResponseStatus::Completed) {
1484+
map.insert(
1485+
"completed_at".into(),
1486+
serde_json::json!(chrono::Utc::now().timestamp() as f64),
1487+
);
1488+
}
1489+
for (k, v) in echo_fields {
1490+
// Don't overwrite reasoning if the struct already has it set from conversion
1491+
if k == "reasoning" && self.reasoning.is_some() {
1492+
continue;
1493+
}
1494+
map.insert(k, v);
1495+
}
1496+
}
1497+
val
1498+
}
1499+
}
1500+
1501+
/// Build a Responses API `response` JSON object for streaming events.
1502+
///
1503+
/// Shared by all provider stream transformers (Anthropic, Bedrock, Vertex).
1504+
pub fn build_streaming_response_json(
1505+
id: &str,
1506+
model: &str,
1507+
created_at: f64,
1508+
status: &str,
1509+
output: serde_json::Value,
1510+
echo_fields: &serde_json::Map<String, serde_json::Value>,
1511+
) -> serde_json::Map<String, serde_json::Value> {
1512+
let mut obj = serde_json::Map::new();
1513+
obj.insert("id".into(), serde_json::json!(id));
1514+
obj.insert("object".into(), serde_json::json!("response"));
1515+
obj.insert("created_at".into(), serde_json::json!(created_at));
1516+
obj.insert("model".into(), serde_json::json!(model));
1517+
obj.insert("status".into(), serde_json::json!(status));
1518+
obj.insert("output".into(), output);
1519+
obj.insert("completed_at".into(), serde_json::Value::Null);
1520+
obj.insert("error".into(), serde_json::Value::Null);
1521+
obj.insert("incomplete_details".into(), serde_json::Value::Null);
1522+
obj.insert("usage".into(), serde_json::Value::Null);
1523+
for (k, v) in echo_fields {
1524+
obj.insert(k.clone(), v.clone());
1525+
}
1526+
obj
1527+
}
1528+
1529+
impl CreateResponsesPayload {
1530+
/// Produce a JSON map of echo fields for streaming response.completed events.
1531+
pub fn echo_fields_json(&self) -> serde_json::Map<String, serde_json::Value> {
1532+
let mut m = serde_json::Map::new();
1533+
1534+
// Echo tools with required fields (strict, description) filled in
1535+
let tools_json =
1536+
serde_json::to_value(self.tools.clone().unwrap_or_default()).unwrap_or_default();
1537+
let tools_json = if let serde_json::Value::Array(tools) = tools_json {
1538+
serde_json::Value::Array(
1539+
tools
1540+
.into_iter()
1541+
.map(|mut t| {
1542+
if let serde_json::Value::Object(ref mut obj) = t {
1543+
let is_function =
1544+
obj.get("type").and_then(|v| v.as_str()) == Some("function");
1545+
if is_function {
1546+
obj.entry("strict").or_insert(serde_json::Value::Null);
1547+
obj.entry("description").or_insert(serde_json::Value::Null);
1548+
}
1549+
}
1550+
t
1551+
})
1552+
.collect(),
1553+
)
1554+
} else {
1555+
tools_json
1556+
};
1557+
m.insert("tools".into(), tools_json);
1558+
m.insert(
1559+
"tool_choice".into(),
1560+
serde_json::to_value(
1561+
self.tool_choice
1562+
.clone()
1563+
.unwrap_or(ResponsesToolChoice::String(
1564+
ResponsesToolChoiceDefault::Auto,
1565+
)),
1566+
)
1567+
.unwrap_or_default(),
1568+
);
1569+
m.insert(
1570+
"parallel_tool_calls".into(),
1571+
serde_json::Value::Bool(self.parallel_tool_calls.unwrap_or(true)),
1572+
);
1573+
m.insert(
1574+
"temperature".into(),
1575+
serde_json::json!(self.temperature.unwrap_or(1.0)),
1576+
);
1577+
m.insert("top_p".into(), serde_json::json!(self.top_p.unwrap_or(1.0)));
1578+
m.insert(
1579+
"store".into(),
1580+
serde_json::Value::Bool(self.store.unwrap_or(true)),
1581+
);
1582+
m.insert(
1583+
"background".into(),
1584+
serde_json::Value::Bool(self.background.unwrap_or(false)),
1585+
);
1586+
m.insert(
1587+
"truncation".into(),
1588+
serde_json::to_value(
1589+
self.truncation
1590+
.as_ref()
1591+
.and_then(|v| serde_json::from_value::<ResponsesTruncation>(v.clone()).ok())
1592+
.unwrap_or(ResponsesTruncation::Disabled),
1593+
)
1594+
.unwrap_or_default(),
1595+
);
1596+
m.insert(
1597+
"text".into(),
1598+
serde_json::to_value(self.text.clone().unwrap_or(ResponseTextConfig {
1599+
format: Some(ResponseFormatTextConfig::Text),
1600+
verbosity: None,
1601+
}))
1602+
.unwrap_or_default(),
1603+
);
1604+
m.insert(
1605+
"service_tier".into(),
1606+
serde_json::to_value(
1607+
self.service_tier
1608+
.as_ref()
1609+
.and_then(|v| serde_json::from_value::<ResponsesServiceTier>(v.clone()).ok())
1610+
.unwrap_or(ResponsesServiceTier::Default),
1611+
)
1612+
.unwrap_or_default(),
1613+
);
1614+
m.insert(
1615+
"presence_penalty".into(),
1616+
serde_json::json!(self.presence_penalty.unwrap_or(0.0)),
1617+
);
1618+
m.insert(
1619+
"frequency_penalty".into(),
1620+
serde_json::json!(self.frequency_penalty.unwrap_or(0.0)),
1621+
);
1622+
m.insert(
1623+
"instructions".into(),
1624+
self.instructions
1625+
.as_ref()
1626+
.map(|s| serde_json::Value::String(s.clone()))
1627+
.unwrap_or(serde_json::Value::Null),
1628+
);
1629+
m.insert(
1630+
"previous_response_id".into(),
1631+
self.previous_response_id
1632+
.as_ref()
1633+
.map(|s| serde_json::Value::String(s.clone()))
1634+
.unwrap_or(serde_json::Value::Null),
1635+
);
1636+
m.insert(
1637+
"prompt_cache_key".into(),
1638+
self.prompt_cache_key
1639+
.as_ref()
1640+
.map(|s| serde_json::Value::String(s.clone()))
1641+
.unwrap_or(serde_json::Value::Null),
1642+
);
1643+
m.insert(
1644+
"safety_identifier".into(),
1645+
self.safety_identifier
1646+
.as_ref()
1647+
.map(|s| serde_json::Value::String(s.clone()))
1648+
.unwrap_or(serde_json::Value::Null),
1649+
);
1650+
m.insert(
1651+
"max_output_tokens".into(),
1652+
self.max_output_tokens
1653+
.map(|v| {
1654+
if v.fract() == 0.0 {
1655+
serde_json::json!(v as i64)
1656+
} else {
1657+
serde_json::json!(v)
1658+
}
1659+
})
1660+
.unwrap_or(serde_json::Value::Null),
1661+
);
1662+
m.insert(
1663+
"metadata".into(),
1664+
self.metadata
1665+
.as_ref()
1666+
.map(|md| serde_json::to_value(md).unwrap_or_default())
1667+
.unwrap_or_else(|| serde_json::json!({})),
1668+
);
1669+
m.insert("max_tool_calls".into(), serde_json::Value::Null);
1670+
// top_logprobs is not a request parameter on the Responses API; default to 0 per spec
1671+
m.insert("top_logprobs".into(), serde_json::json!(0));
1672+
// Ensure reasoning is echoed (null if not configured)
1673+
m.insert(
1674+
"reasoning".into(),
1675+
self.reasoning
1676+
.as_ref()
1677+
.map(|r| {
1678+
serde_json::json!({
1679+
"effort": r.effort.as_ref().map(|e| serde_json::to_value(e).unwrap_or_default()),
1680+
"summary": r.summary.as_ref().map(|s| serde_json::to_value(s).unwrap_or_default()),
1681+
})
1682+
})
1683+
.unwrap_or(serde_json::Value::Null),
1684+
);
1685+
m
1686+
}
1687+
}

src/providers/anthropic/convert.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,11 +1050,14 @@ pub fn convert_anthropic_to_responses_response(
10501050
is_byok: None,
10511051
cost_details: None,
10521052
}),
1053+
completed_at: None,
10531054
max_tool_calls: None,
10541055
top_logprobs: None,
10551056
max_output_tokens: None,
10561057
temperature: None,
10571058
top_p: None,
1059+
presence_penalty: None,
1060+
frequency_penalty: None,
10581061
instructions: None,
10591062
metadata: None,
10601063
tools: None,

src/providers/anthropic/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ impl Provider for AnthropicProvider {
291291
.or(self.default_max_tokens)
292292
.unwrap_or(DEFAULT_MAX_TOKENS);
293293

294+
let echo_fields = payload.echo_fields_json();
295+
294296
// Convert Responses API input to Anthropic messages format
295297
let (system, messages) =
296298
convert_responses_input_to_messages(payload.input, payload.instructions.clone());
@@ -384,7 +386,7 @@ impl Provider for AnthropicProvider {
384386
result.map_err(std::io::Error::other)
385387
});
386388
let transformed_stream =
387-
AnthropicToResponsesStream::new(byte_stream, &self.streaming_buffer);
389+
AnthropicToResponsesStream::new(byte_stream, &self.streaming_buffer, echo_fields);
388390

389391
#[cfg(not(target_arch = "wasm32"))]
390392
{
@@ -401,7 +403,7 @@ impl Provider for AnthropicProvider {
401403
payload.reasoning.as_ref(),
402404
payload.user,
403405
);
404-
json_response(status, &responses_response)
406+
json_response(status, &responses_response.to_json_with_echo(echo_fields))
405407
}
406408
}
407409

0 commit comments

Comments
 (0)