mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Expose Guardian assessments in response streams (#57757)
## What changed Decode `response.metadata` Guardian assessments into typed `GuardianAssessment` events over SSE and WebSocket, carrying `response_id`, `item_id`, and `requires_review`. Ignore assessments with missing, empty, or incorrectly typed required fields while preserving independent delivery of other metadata. Expose the assessment type to request-scoped extension interceptors. Keep assessments out of conversation history; they do not authorize tool execution. ## Testing Add SSE coverage for assessment ordering, malformed metadata, and mixed metadata delivery. Extend model request tests to cover SSE and WebSocket interceptor delivery and exclusion from subsequent model input. GitOrigin-RevId: 282b8817ee97346d76b40bcb412eef023df60ff1
This commit is contained in:
@@ -6,6 +6,7 @@ use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
use codex_api::AuthProvider;
|
||||
use codex_api::Compression;
|
||||
use codex_api::GuardianAssessment;
|
||||
use codex_api::Provider;
|
||||
use codex_api::ResponseEvent;
|
||||
use codex_api::ResponsesClient;
|
||||
@@ -15,11 +16,13 @@ use codex_client::Response;
|
||||
use codex_client::StreamResponse;
|
||||
use codex_client::TransportError;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixtureSseTransport {
|
||||
@@ -199,3 +202,268 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()>
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_delivers_guardian_assessments_before_tool_completion() -> Result<()> {
|
||||
let body = build_responses_body(vec![
|
||||
json!({
|
||||
"type": "response.metadata",
|
||||
"response_id": "resp_1",
|
||||
"metadata": {"guardian": {
|
||||
"item_id": "fc_1", "requires_review": false, "future_field": "ignored"
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call", "id": "fc_1", "call_id": "call_1",
|
||||
"name": "example", "arguments": "{}"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.metadata",
|
||||
"response_id": "resp_1",
|
||||
"metadata": {"guardian": {"item_id": "fc_2", "requires_review": true}}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call", "id": "fc_2", "call_id": "call_2",
|
||||
"name": "example", "arguments": "{}"
|
||||
}
|
||||
}),
|
||||
json!({"type": "response.completed", "response": {"id": "resp_1"}}),
|
||||
]);
|
||||
let client = ResponsesClient::new(
|
||||
FixtureSseTransport::new(body),
|
||||
provider("openai"),
|
||||
Arc::new(NoAuth),
|
||||
);
|
||||
let mut stream = client
|
||||
.stream(
|
||||
json!({}),
|
||||
HeaderMap::new(),
|
||||
Compression::None,
|
||||
/*turn_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
let mut assessments = Vec::new();
|
||||
let mut call_ids = Vec::new();
|
||||
let mut completed = false;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event? {
|
||||
ResponseEvent::GuardianAssessment(assessment) => {
|
||||
assessments.push((assessment, call_ids.len()));
|
||||
}
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, .. }) => {
|
||||
call_ids.push(call_id);
|
||||
}
|
||||
ResponseEvent::Completed { .. } => completed = true,
|
||||
ResponseEvent::RateLimits(_) => {}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
assessments,
|
||||
vec![
|
||||
(
|
||||
GuardianAssessment {
|
||||
response_id: "resp_1".into(),
|
||||
item_id: "fc_1".into(),
|
||||
requires_review: false,
|
||||
},
|
||||
0
|
||||
),
|
||||
(
|
||||
GuardianAssessment {
|
||||
response_id: "resp_1".into(),
|
||||
item_id: "fc_2".into(),
|
||||
requires_review: true,
|
||||
},
|
||||
1
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(call_ids, vec!["call_1", "call_2"]);
|
||||
assert!(completed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_ignores_unusable_guardian_metadata() -> Result<()> {
|
||||
let mut events = Vec::new();
|
||||
for (response_id, guardian) in [
|
||||
(json!("resp_1"), json!(null)),
|
||||
(json!("resp_1"), json!({"item_id": "fc_1"})),
|
||||
(
|
||||
json!("resp_1"),
|
||||
json!({"item_id": "fc_1", "requires_review": null}),
|
||||
),
|
||||
(
|
||||
json!("resp_1"),
|
||||
json!({"item_id": "fc_1", "requires_review": "false"}),
|
||||
),
|
||||
(json!("resp_1"), json!({"requires_review": false})),
|
||||
(
|
||||
json!("resp_1"),
|
||||
json!({"item_id": "", "requires_review": false}),
|
||||
),
|
||||
(
|
||||
json!("resp_1"),
|
||||
json!({"item_id": 1, "requires_review": false}),
|
||||
),
|
||||
(
|
||||
json!(""),
|
||||
json!({"item_id": "fc_1", "requires_review": false}),
|
||||
),
|
||||
(
|
||||
json!(null),
|
||||
json!({"item_id": "fc_1", "requires_review": false}),
|
||||
),
|
||||
(
|
||||
json!(1),
|
||||
json!({"item_id": "fc_1", "requires_review": false}),
|
||||
),
|
||||
] {
|
||||
events.push(json!({
|
||||
"type": "response.metadata", "response_id": response_id,
|
||||
"metadata": {"guardian": guardian}
|
||||
}));
|
||||
}
|
||||
events.push(json!({
|
||||
"type": "response.completed", "response": {"id": "resp_1"},
|
||||
"response_id": 1,
|
||||
"metadata": {"guardian": {"item_id": "fc_1", "requires_review": false}}
|
||||
}));
|
||||
let client = ResponsesClient::new(
|
||||
FixtureSseTransport::new(build_responses_body(events)),
|
||||
provider("openai"),
|
||||
Arc::new(NoAuth),
|
||||
);
|
||||
let mut stream = client
|
||||
.stream(
|
||||
json!({}),
|
||||
HeaderMap::new(),
|
||||
Compression::None,
|
||||
/*turn_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
let mut response_ids = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
match event? {
|
||||
ResponseEvent::Completed { response_id, .. } => response_ids.push(response_id),
|
||||
ResponseEvent::RateLimits(_) => {}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(response_ids, vec!["resp_1"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_dispatches_mixed_metadata_independently() -> Result<()> {
|
||||
let body = build_responses_body(vec![
|
||||
json!({
|
||||
"type": "response.metadata", "response_id": "resp_1",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": ["trusted_access_for_cyber"],
|
||||
"openai_chatgpt_moderation_metadata": {"presentation": "inline"},
|
||||
"type": "safety_buffering", "use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"], "retry_model": "gpt-fast",
|
||||
"guardian": {"item_id": "fc_1", "requires_review": false},
|
||||
"future_metadata": {"ignored": true}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.metadata", "response_id": "resp_1",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": ["trusted_access_for_cyber"],
|
||||
"openai_chatgpt_moderation_metadata": {"presentation": "inline"},
|
||||
"type": "safety_buffering", "use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"], "retry_model": "gpt-fast",
|
||||
"guardian": {"item_id": "fc_2", "requires_review": "false"}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.metadata", "response_id": "resp_1",
|
||||
"metadata": {
|
||||
"openai_verification_recommendation": false,
|
||||
"guardian": {"item_id": "fc_3", "requires_review": true}
|
||||
}
|
||||
}),
|
||||
json!({"type": "response.completed", "response": {"id": "resp_1"}}),
|
||||
]);
|
||||
let client = ResponsesClient::new(
|
||||
FixtureSseTransport::new(body),
|
||||
provider("openai"),
|
||||
Arc::new(NoAuth),
|
||||
);
|
||||
let mut stream = client
|
||||
.stream(
|
||||
json!({}),
|
||||
HeaderMap::new(),
|
||||
Compression::None,
|
||||
/*turn_state*/ None,
|
||||
)
|
||||
.await?;
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event?;
|
||||
if !matches!(event, ResponseEvent::RateLimits(_)) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
let [
|
||||
ResponseEvent::ModelVerifications(verifications),
|
||||
ResponseEvent::TurnModerationMetadata(moderation),
|
||||
ResponseEvent::SafetyBuffering(buffering),
|
||||
ResponseEvent::GuardianAssessment(low_risk),
|
||||
ResponseEvent::ModelVerifications(other_verifications),
|
||||
ResponseEvent::TurnModerationMetadata(other_moderation),
|
||||
ResponseEvent::SafetyBuffering(other_buffering),
|
||||
ResponseEvent::GuardianAssessment(review_required),
|
||||
ResponseEvent::Completed { response_id, .. },
|
||||
] = events.as_slice()
|
||||
else {
|
||||
panic!("unexpected metadata delivery: {events:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
verifications,
|
||||
&vec![ModelVerification::TrustedAccessForCyber]
|
||||
);
|
||||
assert_eq!(other_verifications, verifications);
|
||||
assert_eq!(moderation.metadata, json!({"presentation": "inline"}));
|
||||
assert_eq!(other_moderation, moderation);
|
||||
assert_eq!(
|
||||
(
|
||||
&buffering.use_cases,
|
||||
&buffering.reasons,
|
||||
buffering.show_buffering_ui,
|
||||
buffering.faster_model.as_deref(),
|
||||
),
|
||||
(
|
||||
&vec!["cyber".to_string()],
|
||||
&vec!["user_risk".to_string()],
|
||||
true,
|
||||
Some("gpt-fast"),
|
||||
)
|
||||
);
|
||||
assert_eq!(other_buffering, buffering);
|
||||
assert_eq!(
|
||||
(low_risk, review_required),
|
||||
(
|
||||
&GuardianAssessment {
|
||||
response_id: "resp_1".into(),
|
||||
item_id: "fc_1".into(),
|
||||
requires_review: false,
|
||||
},
|
||||
&GuardianAssessment {
|
||||
response_id: "resp_1".into(),
|
||||
item_id: "fc_3".into(),
|
||||
requires_review: true,
|
||||
},
|
||||
)
|
||||
);
|
||||
assert_eq!(response_id, "resp_1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user