mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Treat inference hook denials as terminal errors (#60511)
## Why An explicit denial from a workspace inference hook should stop the request, including retries and compaction model fallback. ## What changed Recognize `inference_hook_denied` provider errors and preserve the provider's message, with a fallback when none is supplied. Expose the distinct `inferenceHookDenied` error through the app-server protocol and generated schemas and SDK types. Treat the denial as non-retryable during turns, compaction, and guardian review. ## Testing Add coverage for terminal denials over HTTP, SSE, and WebSocket, error serialization and persistence in `thread/read`, and compaction failures that preserve token usage and history without retrying or falling back to another model. GitOrigin-RevId: 2ebd480f01e52f4e6bd3db8a0f92d5814d18ccb1
This commit is contained in:
@@ -729,6 +729,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
+7
@@ -8925,6 +8925,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
+7
@@ -4706,6 +4706,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -208,6 +208,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
+7
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -208,6 +208,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -208,6 +208,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -130,6 +130,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The workspace's inference hook explicitly denied this request.",
|
||||
"enum": [
|
||||
"inferenceHookDenied"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
*/
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown };
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "inferenceHookDenied" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other" | string | { [key: string]: unknown };
|
||||
|
||||
@@ -83,6 +83,8 @@ pub enum CodexErrorInfo {
|
||||
ModelNotFound,
|
||||
// Capacity is unavailable on a route restricted by server policy.
|
||||
PolicyCapacityUnavailable,
|
||||
/// The workspace's inference hook explicitly denied this request.
|
||||
InferenceHookDenied,
|
||||
CyberPolicy,
|
||||
MisalignmentPolicyViolation,
|
||||
TooManyDenials,
|
||||
@@ -163,6 +165,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
CodexErrorInfo::PolicyCapacityUnavailable
|
||||
}
|
||||
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
|
||||
CoreCodexErrorInfo::InferenceHookDenied => CodexErrorInfo::InferenceHookDenied,
|
||||
CoreCodexErrorInfo::BioPolicy => CodexErrorInfo::Other,
|
||||
CoreCodexErrorInfo::MisalignmentPolicyViolation => {
|
||||
CodexErrorInfo::MisalignmentPolicyViolation
|
||||
|
||||
@@ -4907,6 +4907,10 @@ fn core_error_info_converts_to_camel_case() {
|
||||
json!("policyCapacityUnavailable"),
|
||||
),
|
||||
(CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")),
|
||||
(
|
||||
CoreCodexErrorInfo::InferenceHookDenied,
|
||||
json!("inferenceHookDenied"),
|
||||
),
|
||||
(CoreCodexErrorInfo::BioPolicy, json!("other")),
|
||||
(CoreCodexErrorInfo::InvalidPrompt, json!("other")),
|
||||
(
|
||||
@@ -4918,9 +4922,11 @@ fn core_error_info_converts_to_camel_case() {
|
||||
json!("rateLimitExceeded"),
|
||||
),
|
||||
] {
|
||||
let converted = CodexErrorInfo::from(core);
|
||||
assert_eq!(serde_json::to_value(&converted).unwrap(), expected);
|
||||
assert_eq!(
|
||||
serde_json::to_value(CodexErrorInfo::from(core)).unwrap(),
|
||||
expected
|
||||
serde_json::from_value::<CodexErrorInfo>(expected).unwrap(),
|
||||
converted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2818,15 +2818,17 @@ async fn thread_items_read_validates_ids_before_history_access() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case("model_not_found", CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model_not_found")]
|
||||
#[test_case::test_case("inference_hook_denied", CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")]
|
||||
#[tokio::test]
|
||||
async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()> {
|
||||
async fn thread_read_preserves_provider_error_after_failed_turn(
|
||||
code: &str,
|
||||
expected: CodexErrorInfo,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let message = "Access to this deployment ended.";
|
||||
let _response_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse_failed("resp-1", "model_not_found", message),
|
||||
)
|
||||
.await;
|
||||
let response_mock =
|
||||
responses::mount_sse_once(&server, responses::sse_failed("resp-1", code, message)).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
|
||||
@@ -2858,10 +2860,7 @@ async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()>
|
||||
let live: ErrorNotification =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("error")).await??;
|
||||
assert!(!live.will_retry);
|
||||
assert_eq!(
|
||||
live.error.codex_error_info,
|
||||
Some(CodexErrorInfo::ModelNotFound)
|
||||
);
|
||||
assert_eq!(live.error.codex_error_info, Some(expected));
|
||||
assert_eq!(live.error.message, message);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -2883,6 +2882,7 @@ async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()>
|
||||
thread.turns.last().and_then(|turn| turn.error.as_ref()),
|
||||
Some(&live.error)
|
||||
);
|
||||
assert_eq!(response_mock.requests().len(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2534,6 +2534,9 @@ fn websocket_error_detail(err: &ApiError) -> String {
|
||||
"handshake error: model not found: [redacted]".to_string()
|
||||
}
|
||||
ApiError::InvalidPrompt { .. } => "handshake error: invalid prompt: [redacted]".to_string(),
|
||||
ApiError::InferenceHookDenied { .. } => {
|
||||
"handshake error: inference hook denied: [redacted]".to_string()
|
||||
}
|
||||
ApiError::ContentPolicyViolation { .. } => {
|
||||
"handshake error: content policy violation".to_string()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
| ApiError::InvalidAudioInput { .. }
|
||||
| ApiError::ModelNotFound { .. }
|
||||
| ApiError::InvalidPrompt { .. }
|
||||
| ApiError::InferenceHookDenied { .. }
|
||||
| ApiError::ContentPolicyViolation { .. }
|
||||
| ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. }
|
||||
@@ -105,6 +106,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
|
||||
ApiError::InvalidPrompt { message } => {
|
||||
CodexErr::new(CodexErrorDetails::InvalidPrompt { message })
|
||||
}
|
||||
ApiError::InferenceHookDenied { message } => {
|
||||
CodexErr::new(CodexErrorDetails::InferenceHookDenied { message })
|
||||
}
|
||||
ApiError::ContentPolicyViolation { message, status } => {
|
||||
CodexErrorDetails::ContentPolicyViolation { message, status }.into()
|
||||
}
|
||||
|
||||
@@ -335,6 +335,12 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
|
||||
fn map_api_error_preserves_typed_errors() {
|
||||
let message = "This request was rejected.";
|
||||
for (error, expected_info) in [
|
||||
(
|
||||
ApiError::InferenceHookDenied {
|
||||
message: message.to_string(),
|
||||
},
|
||||
CodexErrorInfo::InferenceHookDenied,
|
||||
),
|
||||
(
|
||||
ApiError::BioPolicy {
|
||||
message: message.to_string(),
|
||||
@@ -357,22 +363,31 @@ fn map_api_error_preserves_typed_errors() {
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_http_and_wrapped_websocket_typed_errors() {
|
||||
for (code, expected_info, fallback) in [
|
||||
for (status, code, expected_info, fallback) in [
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"content_policy_violation",
|
||||
CodexErrorInfo::InvalidPrompt,
|
||||
"The input was rejected by the content policy.",
|
||||
),
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"bio_policy",
|
||||
CodexErrorInfo::BioPolicy,
|
||||
"This content was flagged for possible biological risk.",
|
||||
),
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"invalid_prompt",
|
||||
CodexErrorInfo::InvalidPrompt,
|
||||
"Invalid request.",
|
||||
),
|
||||
(
|
||||
http::StatusCode::FORBIDDEN,
|
||||
"inference_hook_denied",
|
||||
CodexErrorInfo::InferenceHookDenied,
|
||||
"Your workspace inference hook blocked this request.",
|
||||
),
|
||||
] {
|
||||
for wrapped in [false, true] {
|
||||
for (message, expected) in [
|
||||
@@ -390,11 +405,11 @@ fn map_api_error_maps_http_and_wrapped_websocket_typed_errors() {
|
||||
}
|
||||
if wrapped {
|
||||
body["type"] = serde_json::json!("error");
|
||||
body["status"] = serde_json::json!(400);
|
||||
body["status"] = serde_json::json!(status.as_u16());
|
||||
}
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
retry_after: None,
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
status,
|
||||
url: None,
|
||||
headers: None,
|
||||
body: Some(body.to_string()),
|
||||
|
||||
@@ -57,6 +57,8 @@ pub enum ApiError {
|
||||
#[error("invalid prompt: {message}")]
|
||||
InvalidPrompt { message: String },
|
||||
#[error("{message}")]
|
||||
InferenceHookDenied { message: String },
|
||||
#[error("{message}")]
|
||||
ContentPolicyViolation {
|
||||
message: String,
|
||||
status: Option<StatusCode>,
|
||||
@@ -96,6 +98,13 @@ pub(crate) fn parse_terminal_provider_error(
|
||||
.and_then(Value::as_str)
|
||||
.filter(|message| !message.trim().is_empty());
|
||||
match (code, status) {
|
||||
("inference_hook_denied", None | Some(StatusCode::FORBIDDEN)) => {
|
||||
Some(ApiError::InferenceHookDenied {
|
||||
message: message
|
||||
.unwrap_or("Your workspace inference hook blocked this request.")
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
("context_length_exceeded", None | Some(StatusCode::BAD_REQUEST)) => {
|
||||
Some(ApiError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
@@ -683,6 +683,7 @@ async fn process_sse_with_treatment(
|
||||
| ApiError::ContextWindowExceeded
|
||||
| ApiError::InvalidRequest { .. }
|
||||
| ApiError::SessionBudgetExceeded
|
||||
| ApiError::InferenceHookDenied { .. }
|
||||
| ApiError::AccessProgramNotEnabled { .. }
|
||||
| ApiError::ContentPolicyViolation { .. }
|
||||
| ApiError::ResponseIncomplete(_)
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn terminal_failure_with_retry_header_ends_stream_immediately() {
|
||||
for (code, expected) in [
|
||||
("flex_unavailable", CodexErrorInfo::FlexUnavailable),
|
||||
("budget_reached", CodexErrorInfo::SessionBudgetExceeded),
|
||||
("inference_hook_denied", CodexErrorInfo::InferenceHookDenied),
|
||||
(
|
||||
"context_length_exceeded",
|
||||
CodexErrorInfo::ContextWindowExceeded,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) fn should_retry_with_current_model(
|
||||
CodexErrorDetails::TurnAborted
|
||||
| CodexErrorDetails::Interrupted
|
||||
| CodexErrorDetails::SessionBudgetExceeded
|
||||
| CodexErrorDetails::InferenceHookDenied { .. }
|
||||
| CodexErrorDetails::WorkspaceAccessDenied(_)
|
||||
| CodexErrorDetails::ContentPolicyViolation { .. }
|
||||
| CodexErrorDetails::ResponseIncomplete(_)
|
||||
|
||||
@@ -2036,7 +2036,9 @@ async fn run_sampling_request(
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
CodexErrorDetails::SessionBudgetExceeded | CodexErrorDetails::Fatal(_) => {
|
||||
CodexErrorDetails::SessionBudgetExceeded
|
||||
| CodexErrorDetails::InferenceHookDenied { .. }
|
||||
| CodexErrorDetails::Fatal(_) => {
|
||||
return Err(err);
|
||||
}
|
||||
_ => err,
|
||||
|
||||
@@ -1200,36 +1200,78 @@ async fn explicit_denial_stops_compaction_on_model_downshift(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(json!({"input_tokens": 120_000, "output_tokens": 30_000, "total_tokens": 150_000}); "reported_usage")]
|
||||
#[test_case::test_case(Value::Null; "missing_usage")]
|
||||
#[test_case::test_case(
|
||||
json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "incomplete-compaction",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"usage": {"input_tokens": 120_000, "output_tokens": 30_000, "total_tokens": 150_000}
|
||||
}
|
||||
}),
|
||||
"Incomplete response returned, reason: max_output_tokens",
|
||||
CodexErrorInfo::Other,
|
||||
270_000;
|
||||
"incomplete_reported_usage"
|
||||
)]
|
||||
#[test_case::test_case(
|
||||
json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "incomplete-compaction",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"usage": null
|
||||
}
|
||||
}),
|
||||
"Incomplete response returned, reason: max_output_tokens",
|
||||
CodexErrorInfo::Other,
|
||||
120_000;
|
||||
"incomplete_missing_usage"
|
||||
)]
|
||||
#[test_case::test_case(
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": {"code": "inference_hook_denied", "message": "Blocked by the workspace hook."}
|
||||
}),
|
||||
"Blocked by the workspace hook.",
|
||||
CodexErrorInfo::InferenceHookDenied,
|
||||
120_000;
|
||||
"inference_hook_denied_error"
|
||||
)]
|
||||
#[test_case::test_case(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "denied-compaction",
|
||||
"error": {"code": "inference_hook_denied", "message": "Blocked by the workspace hook."}
|
||||
}
|
||||
}),
|
||||
"Blocked by the workspace hook.",
|
||||
CodexErrorInfo::InferenceHookDenied,
|
||||
120_000;
|
||||
"inference_hook_denied_response_failed"
|
||||
)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn incomplete_response_during_compaction_is_terminal_and_counted(
|
||||
reported_usage: Value,
|
||||
async fn terminal_compaction_failure_preserves_usage_and_history(
|
||||
failure_event: Value,
|
||||
expected_error: &str,
|
||||
expected_error_info: CodexErrorInfo,
|
||||
expected_total_tokens: i64,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
for (provider, expected_error) in [
|
||||
for (provider, error_prefix) in [
|
||||
(
|
||||
non_openai_model_provider as fn(&MockServer) -> ModelProviderInfo,
|
||||
"Incomplete response returned, reason: max_output_tokens",
|
||||
),
|
||||
(
|
||||
openai_model_provider,
|
||||
"Error running remote compact task: Incomplete response returned, reason: max_output_tokens",
|
||||
"",
|
||||
),
|
||||
(openai_model_provider, "Error running remote compact task: "),
|
||||
] {
|
||||
let server = start_mock_server().await;
|
||||
let previous_model = "gpt-5.4";
|
||||
let selected_model = "gpt-5.2";
|
||||
let incomplete = sse(vec![
|
||||
ev_assistant_message("discarded", "DISCARDED_INCOMPLETE_SUMMARY"),
|
||||
json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "incomplete-compaction",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"usage": reported_usage
|
||||
}
|
||||
}),
|
||||
let failure = sse(vec![
|
||||
ev_assistant_message("discarded", "DISCARDED_FAILED_SUMMARY"),
|
||||
failure_event.clone(),
|
||||
]);
|
||||
let response_mock = mount_sse_sequence(
|
||||
&server,
|
||||
@@ -1238,8 +1280,8 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted(
|
||||
ev_assistant_message("initial", "Initial reply."),
|
||||
ev_completed_with_tokens("initial", /*total_tokens*/ 120_000),
|
||||
]),
|
||||
incomplete.clone(),
|
||||
incomplete,
|
||||
failure.clone(),
|
||||
failure,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
@@ -1270,7 +1312,7 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted(
|
||||
// includes request overhead that the local history estimate does not count.
|
||||
test.submit_turn(&"x".repeat(440_000)).await?;
|
||||
// A smaller selected model forces compaction with the previous model.
|
||||
// An incomplete result must stop both retries and fallback to the selected model.
|
||||
// A terminal failure must stop both retries and fallback to the selected model.
|
||||
test.codex
|
||||
.start_or_steer_turn(
|
||||
TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
@@ -1293,29 +1335,33 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted(
|
||||
let mut errors = Vec::new();
|
||||
wait_for_event(&test.codex, |event| {
|
||||
match event {
|
||||
EventMsg::Error(error) => errors.push(error.message.clone()),
|
||||
EventMsg::Error(error) => {
|
||||
errors.push((error.message.clone(), error.codex_error_info.clone()));
|
||||
}
|
||||
EventMsg::StreamError(value) => panic!("unexpected compaction retry: {value:?}"),
|
||||
EventMsg::RawResponseCompleted(value) => {
|
||||
panic!("incomplete response marked complete: {value:?}")
|
||||
panic!("failed response marked complete: {value:?}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(errors, vec![expected_error]);
|
||||
assert_eq!(
|
||||
errors,
|
||||
vec![(
|
||||
format!("{error_prefix}{expected_error}"),
|
||||
Some(expected_error_info.clone())
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
test.codex
|
||||
.token_usage_info()
|
||||
.await
|
||||
.expect("partial usage")
|
||||
.expect("token usage")
|
||||
.total_token_usage
|
||||
.total_tokens,
|
||||
if reported_usage.is_null() {
|
||||
120_000
|
||||
} else {
|
||||
270_000
|
||||
},
|
||||
expected_total_tokens,
|
||||
);
|
||||
assert_eq!(
|
||||
response_mock
|
||||
@@ -1346,7 +1392,7 @@ async fn incomplete_response_during_compaction_is_terminal_and_counted(
|
||||
);
|
||||
assert!(!body_contains_text(
|
||||
&requests[2].body_json().to_string(),
|
||||
"DISCARDED_INCOMPLETE_SUMMARY",
|
||||
"DISCARDED_FAILED_SUMMARY",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1446,6 +1446,7 @@ async fn assert_terminal_failure(
|
||||
|
||||
#[test_case::test_case("flex_unavailable", 429, CodexErrorInfo::FlexUnavailable, "Flex capacity unavailable."; "flex")]
|
||||
#[test_case::test_case("model_not_found", 404, CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model")]
|
||||
#[test_case::test_case("inference_hook_denied", 403, CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_and_sse_provider_errors_are_terminal(
|
||||
code: &str,
|
||||
@@ -1503,6 +1504,7 @@ async fn http_and_sse_provider_errors_are_terminal(
|
||||
|
||||
#[test_case::test_case("flex_unavailable", 429, CodexErrorInfo::FlexUnavailable, "Flex capacity unavailable."; "flex")]
|
||||
#[test_case::test_case("model_not_found", 404, CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model")]
|
||||
#[test_case::test_case("inference_hook_denied", 403, CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_provider_errors_are_terminal(
|
||||
code: &str,
|
||||
|
||||
@@ -197,6 +197,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
|
||||
CodexErrorInfo::ContextWindowExceeded
|
||||
| CodexErrorInfo::SessionBudgetExceeded
|
||||
| CodexErrorInfo::UsageLimitExceeded
|
||||
| CodexErrorInfo::InferenceHookDenied
|
||||
| CodexErrorInfo::CyberPolicy
|
||||
| CodexErrorInfo::BioPolicy
|
||||
| CodexErrorInfo::MisalignmentPolicyViolation
|
||||
|
||||
@@ -114,6 +114,13 @@ fn guardian_review_retry_only_retries_recoverable_errors() {
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("workspace inference hook denied the request"),
|
||||
CodexErrorInfo::InferenceHookDenied,
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewSessionOutcome::Completed(Ok(Some(GuardianReviewResponse::new(
|
||||
"invalid assessment".to_owned(),
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str {
|
||||
ApiError::ContentPolicyViolation { .. } => "content_policy_violation",
|
||||
ApiError::CyberPolicy { .. }
|
||||
| ApiError::BioPolicy { .. }
|
||||
| ApiError::InferenceHookDenied { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. } => "policy_error",
|
||||
ApiError::ServerOverloaded { .. } => "server_overloaded",
|
||||
ApiError::PolicyCapacityUnavailable { .. } => "policy_capacity_unavailable",
|
||||
|
||||
@@ -163,6 +163,7 @@ impl SamplingExecution {
|
||||
| ApiError::InvalidAudioInput { .. }
|
||||
| ApiError::ModelNotFound { .. }
|
||||
| ApiError::InvalidPrompt { .. }
|
||||
| ApiError::InferenceHookDenied { .. }
|
||||
| ApiError::AccessProgramNotEnabled { .. }
|
||||
| ApiError::ContentPolicyViolation { .. }
|
||||
| ApiError::MisalignmentPolicyViolation { .. }
|
||||
|
||||
@@ -26,6 +26,7 @@ enum CodexErrorInfoWire {
|
||||
ModelNotFound,
|
||||
/// Capacity is unavailable on a route restricted by server policy.
|
||||
PolicyCapacityUnavailable,
|
||||
InferenceHookDenied,
|
||||
CyberPolicy,
|
||||
BioPolicy,
|
||||
MisalignmentPolicyViolation,
|
||||
|
||||
@@ -173,6 +173,9 @@ pub enum CodexErrorDetails {
|
||||
},
|
||||
#[error("{message}")]
|
||||
InvalidPrompt { message: String },
|
||||
/// The workspace's inference hook explicitly denied this request.
|
||||
#[error("{message}")]
|
||||
InferenceHookDenied { message: String },
|
||||
/// The provider rejected the input under its content policy.
|
||||
#[error("{message}")]
|
||||
ContentPolicyViolation {
|
||||
@@ -463,6 +466,7 @@ impl CodexErr {
|
||||
| CodexErrorDetails::ResponseIncomplete(_)
|
||||
| CodexErrorDetails::ModelNotFound { .. }
|
||||
| CodexErrorDetails::InvalidPrompt { .. }
|
||||
| CodexErrorDetails::InferenceHookDenied { .. }
|
||||
| CodexErrorDetails::ContentPolicyViolation { .. }
|
||||
| CodexErrorDetails::ToolCollision(_)
|
||||
| CodexErrorDetails::RefreshTokenFailed(_)
|
||||
@@ -544,6 +548,7 @@ impl CodexErr {
|
||||
CodexErrorInfo::PolicyCapacityUnavailable
|
||||
}
|
||||
CodexErrorDetails::FlexUnavailable => CodexErrorInfo::FlexUnavailable,
|
||||
CodexErrorDetails::InferenceHookDenied { .. } => CodexErrorInfo::InferenceHookDenied,
|
||||
CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
|
||||
CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy,
|
||||
CodexErrorDetails::ModelNotFound { .. } => CodexErrorInfo::ModelNotFound,
|
||||
|
||||
@@ -2106,6 +2106,8 @@ pub enum CodexErrorInfo {
|
||||
ModelNotFound,
|
||||
/// Capacity is unavailable on a route restricted by server policy.
|
||||
PolicyCapacityUnavailable,
|
||||
/// The workspace's inference hook explicitly denied this request.
|
||||
InferenceHookDenied,
|
||||
CyberPolicy,
|
||||
BioPolicy,
|
||||
MisalignmentPolicyViolation,
|
||||
@@ -2153,6 +2155,7 @@ impl CodexErrorInfo {
|
||||
| Self::ServerOverloaded
|
||||
| Self::ModelNotFound
|
||||
| Self::PolicyCapacityUnavailable
|
||||
| Self::InferenceHookDenied
|
||||
| Self::CyberPolicy
|
||||
| Self::BioPolicy
|
||||
| Self::MisalignmentPolicyViolation
|
||||
|
||||
@@ -99,6 +99,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String {
|
||||
| ApiError::InvalidAudioInput { .. }
|
||||
| ApiError::InvalidPrompt { .. } => "invalid request".to_string(),
|
||||
ApiError::CyberPolicy { .. } => "cyber policy".to_string(),
|
||||
ApiError::InferenceHookDenied { .. } => "inference hook denied".to_string(),
|
||||
ApiError::BioPolicy { .. } => "bio policy".to_string(),
|
||||
ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(),
|
||||
ApiError::ServerOverloaded { .. } => "server overloaded".to_string(),
|
||||
|
||||
@@ -8376,6 +8376,7 @@ class ActiveTurnNotSteerableCodexErrorInfo(BaseModel):
|
||||
class CodexErrorInfo(
|
||||
RootModel[
|
||||
CodexErrorInfoValue
|
||||
| Literal["inferenceHookDenied"]
|
||||
| HttpConnectionFailedCodexErrorInfo
|
||||
| ResponseStreamConnectionFailedCodexErrorInfo
|
||||
| ResponseStreamDisconnectedCodexErrorInfo
|
||||
@@ -8386,6 +8387,7 @@ class CodexErrorInfo(
|
||||
):
|
||||
root: Annotated[
|
||||
CodexErrorInfoValue
|
||||
| Literal["inferenceHookDenied"]
|
||||
| HttpConnectionFailedCodexErrorInfo
|
||||
| ResponseStreamConnectionFailedCodexErrorInfo
|
||||
| ResponseStreamDisconnectedCodexErrorInfo
|
||||
|
||||
Reference in New Issue
Block a user