mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Handle unknown error classifications and configure gateway OAuth (#46482)
Preserve known error classifications, including BioPolicy, while falling back to Other for unknown values so saved sessions can still be read. Add optional gateway OAuth settings to model provider configuration. Co-authored-by: Owen Lin <owen@openai.com> Co-authored-by: alexsong-oai <alexsong@openai.com> GitOrigin-RevId: 64bcf45ca042efca7645c798db3746375426b0fe
This commit is contained in:
committed by
copyberry
co-authored by
Owen Lin
alexsong-oai
parent
7498521d28
commit
3d5b66c655
Generated
+1
@@ -4079,6 +4079,7 @@ dependencies = [
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -598,6 +598,7 @@ mod thread_processor_behavior_tests {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -302,6 +302,7 @@ mod tests {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -180,6 +180,7 @@ fn model_provider_from_proto(
|
||||
.auth
|
||||
.map(model_provider_auth_from_proto)
|
||||
.transpose()?,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api,
|
||||
query_params: provider.query_params.map(redacted_string_map),
|
||||
@@ -208,6 +209,7 @@ fn model_provider_to_proto(
|
||||
env_key_instructions,
|
||||
experimental_bearer_token,
|
||||
auth,
|
||||
gateway_oauth: _,
|
||||
aws: _,
|
||||
wire_api,
|
||||
query_params,
|
||||
@@ -568,6 +570,7 @@ mod tests {
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: true,
|
||||
supports_standalone_web_search: true,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,6 +1567,91 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"GatewayOAuthConfig": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"authorization_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"delivery": {
|
||||
"$ref": "#/definitions/GatewayOAuthDelivery"
|
||||
},
|
||||
"redirect_port": {
|
||||
"format": "uint16",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"token_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authorization_url",
|
||||
"client_id",
|
||||
"delivery",
|
||||
"token_url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthDelivery": {
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"header"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheme": {
|
||||
"default": "Bearer",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"cookie"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"GhostSnapshotToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -2449,6 +2534,14 @@
|
||||
"description": "Value to use with `Authorization: Bearer <token>` header. Use of this config is discouraged in favor of `env_key` for security reasons, but this may be necessary when using this programmatically.",
|
||||
"type": "string"
|
||||
},
|
||||
"gateway_oauth": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GatewayOAuthConfig"
|
||||
}
|
||||
],
|
||||
"description": "Secondary OAuth credentials required by the provider's gateway."
|
||||
},
|
||||
"http_headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
||||
@@ -78,6 +78,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -217,6 +218,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -337,6 +339,7 @@ async fn responses_respects_model_info_overrides_from_config() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -1608,6 +1608,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: Some(auth),
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -3108,6 +3109,7 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids(
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -3735,6 +3737,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
env_key: Some(EXISTING_ENV_VAR_WITH_NON_EMPTY_VALUE.to_string()),
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
query_params: Some(std::collections::HashMap::from([(
|
||||
"api-version".to_string(),
|
||||
@@ -3824,6 +3827,7 @@ async fn env_var_overrides_loaded_auth() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
http_headers: Some(std::collections::HashMap::from([(
|
||||
|
||||
@@ -2725,6 +2725,7 @@ fn websocket_provider_with_connect_timeout(
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -70,6 +70,7 @@ async fn continue_after_stream_error() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -55,6 +55,7 @@ async fn retries_on_early_close() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -65,6 +65,7 @@ mod tests {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -19,6 +19,7 @@ codex-utils-redacted-string = { workspace = true }
|
||||
http = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Secondary OAuth credentials delivered alongside the provider's primary authentication.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use http::header::HeaderName;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use url::Host;
|
||||
use url::Url;
|
||||
|
||||
use crate::ModelProviderInfo;
|
||||
|
||||
// Reserve authentication, routing/framing, and internal protocol headers.
|
||||
const RESERVED_GATEWAY_OAUTH_HEADERS: &[&str] = &[
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"chatgpt-account-id",
|
||||
];
|
||||
const RESERVED_GATEWAY_OAUTH_HEADER_PREFIXES: &[&str] =
|
||||
&["x-codex-", "x-openai-", "sec-websocket-"];
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GatewayOAuthConfig {
|
||||
pub authorization_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub resource: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
pub redirect_port: Option<u16>,
|
||||
pub delivery: GatewayOAuthDelivery,
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayOAuthConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GatewayOAuthConfig")
|
||||
.field("delivery", &self.delivery)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum GatewayOAuthDelivery {
|
||||
Header {
|
||||
name: String,
|
||||
#[serde(default = "bearer_scheme")]
|
||||
scheme: String,
|
||||
},
|
||||
Cookie {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn bearer_scheme() -> String {
|
||||
"Bearer".to_owned()
|
||||
}
|
||||
|
||||
impl GatewayOAuthConfig {
|
||||
pub(crate) fn validate(&self, provider: &ModelProviderInfo) -> Result<(), String> {
|
||||
if provider.aws.is_some() || provider.is_amazon_bedrock() {
|
||||
return Err("provider gateway_oauth cannot be combined with AWS authentication".into());
|
||||
}
|
||||
validate_url(&self.authorization_url, "gateway_oauth.authorization_url")?;
|
||||
validate_url(&self.token_url, "gateway_oauth.token_url")?;
|
||||
let base_url = provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.ok_or("gateway_oauth requires base_url")?;
|
||||
validate_url(base_url, "base_url")?;
|
||||
if self.client_id.trim().is_empty() || self.redirect_port == Some(0) {
|
||||
return Err(
|
||||
"gateway_oauth requires a nonempty client_id and a nonzero redirect_port".into(),
|
||||
);
|
||||
}
|
||||
let header = match &self.delivery {
|
||||
GatewayOAuthDelivery::Header { name, scheme } => {
|
||||
let header = HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| "invalid gateway_oauth header name")?;
|
||||
if !is_token(scheme)
|
||||
|| RESERVED_GATEWAY_OAUTH_HEADERS.contains(&header.as_str())
|
||||
|| RESERVED_GATEWAY_OAUTH_HEADER_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| header.as_str().starts_with(prefix))
|
||||
{
|
||||
return Err(
|
||||
"invalid or reserved gateway_oauth delivery header or scheme".into(),
|
||||
);
|
||||
}
|
||||
header
|
||||
}
|
||||
GatewayOAuthDelivery::Cookie { name } => {
|
||||
if !is_token(name) {
|
||||
return Err("invalid gateway_oauth cookie name".into());
|
||||
}
|
||||
http::header::COOKIE
|
||||
}
|
||||
};
|
||||
if provider
|
||||
.http_headers
|
||||
.iter()
|
||||
.flat_map(|headers| headers.keys())
|
||||
.chain(
|
||||
provider
|
||||
.env_http_headers
|
||||
.iter()
|
||||
.flat_map(|headers| headers.keys()),
|
||||
)
|
||||
.any(|name| name.eq_ignore_ascii_case(header.as_str()))
|
||||
{
|
||||
return Err(
|
||||
"gateway_oauth delivery conflicts with a configured provider header".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_token(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
|
||||
}
|
||||
|
||||
fn validate_url(value: &str, field: &str) -> Result<(), String> {
|
||||
let url = Url::parse(value).map_err(|_| format!("invalid {field} URL"))?;
|
||||
let loopback = match url.host() {
|
||||
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
||||
Some(Host::Ipv4(host)) => host.is_loopback(),
|
||||
Some(Host::Ipv6(host)) => host.is_loopback(),
|
||||
None => false,
|
||||
};
|
||||
if url.host().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| !(url.scheme() == "https" || (url.scheme() == "http" && loopback))
|
||||
{
|
||||
return Err(format!(
|
||||
"{field} must use HTTPS (or loopback HTTP), without userinfo or a fragment"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gateway_oauth_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,79 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn provider(extra: &str) -> ModelProviderInfo {
|
||||
toml::from_str(&format!(
|
||||
r#"
|
||||
base_url = "https://gateway.example.test/v1"
|
||||
[gateway_oauth]
|
||||
authorization_url = "https://login.example.test/authorize"
|
||||
token_url = "https://login.example.test/token"
|
||||
client_id = "codex"
|
||||
{extra}
|
||||
"#
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_header_and_cookie_delivery() {
|
||||
let header = provider("delivery = { kind = 'header', name = 'x-gateway-auth' }");
|
||||
assert_eq!(header.validate(), Ok(()));
|
||||
assert_eq!(
|
||||
header.gateway_oauth.as_ref().unwrap().delivery,
|
||||
GatewayOAuthDelivery::Header {
|
||||
name: "x-gateway-auth".into(),
|
||||
scheme: "Bearer".into(),
|
||||
}
|
||||
);
|
||||
let cookie = provider("delivery = { kind = 'cookie', name = 'gateway_session' }");
|
||||
assert_eq!(cookie.validate(), Ok(()));
|
||||
assert_eq!(
|
||||
toml::from_str::<ModelProviderInfo>(&toml::to_string(&cookie).unwrap()).unwrap(),
|
||||
cookie
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_endpoints_and_delivery_collisions() {
|
||||
let valid = provider("delivery = { kind = 'header', name = 'x-gateway-auth' }");
|
||||
for url in [
|
||||
"http://remote.example.test",
|
||||
"https://user:secret@gateway.example.test",
|
||||
"https://gateway.example.test/#secret",
|
||||
"file:///token",
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid.base_url = Some(url.into());
|
||||
assert!(invalid.validate().is_err());
|
||||
invalid = valid.clone();
|
||||
invalid.gateway_oauth.as_mut().unwrap().token_url = url.into();
|
||||
let error = invalid.validate().unwrap_err();
|
||||
assert!(!error.contains(url));
|
||||
}
|
||||
for name in [
|
||||
"Authorization",
|
||||
"HOST",
|
||||
"x-openai-actor-authorization",
|
||||
"content-length",
|
||||
"sec-websocket-key",
|
||||
"bad name",
|
||||
] {
|
||||
let invalid = provider(&format!(
|
||||
"delivery = {{ kind = 'header', name = '{name}' }}"
|
||||
));
|
||||
assert!(invalid.validate().is_err(), "{name}");
|
||||
}
|
||||
let mut collision = valid;
|
||||
collision.http_headers = Some(std::collections::HashMap::from([(
|
||||
"X-Gateway-Auth".into(),
|
||||
"static".into(),
|
||||
)]));
|
||||
assert!(collision.validate().is_err());
|
||||
let mut cookie = provider("delivery = { kind = 'cookie', name = 'gateway' }");
|
||||
cookie.env_http_headers = Some(std::collections::HashMap::from([(
|
||||
"COOKIE".into(),
|
||||
"COOKIE_ENV".into(),
|
||||
)]));
|
||||
assert!(cookie.validate().is_err());
|
||||
}
|
||||
@@ -31,6 +31,10 @@ use std::sync::PoisonError;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
mod gateway_oauth;
|
||||
pub use gateway_oauth::GatewayOAuthConfig;
|
||||
pub use gateway_oauth::GatewayOAuthDelivery;
|
||||
|
||||
pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -145,6 +149,8 @@ pub struct ModelProviderInfo {
|
||||
pub experimental_bearer_token: Option<RedactedString>,
|
||||
/// Command-backed bearer-token configuration for this provider.
|
||||
pub auth: Option<ModelProviderAuthInfo>,
|
||||
/// Secondary OAuth credentials required by the provider's gateway.
|
||||
pub gateway_oauth: Option<GatewayOAuthConfig>,
|
||||
/// AWS SigV4 auth configuration for this provider.
|
||||
pub aws: Option<ModelProviderAwsAuthInfo>,
|
||||
/// Which wire protocol this provider expects.
|
||||
@@ -274,6 +280,9 @@ other non-default provider fields are not supported"
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> std::result::Result<(), String> {
|
||||
if let Some(gateway) = &self.gateway_oauth {
|
||||
gateway.validate(self)?;
|
||||
}
|
||||
if let Some(aws) = self.aws.as_ref() {
|
||||
if self.supports_websockets {
|
||||
// TODO(celia-oai): Support AWS SigV4 signing for WebSocket
|
||||
@@ -505,6 +514,7 @@ other non-default provider fields are not supported"
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -548,6 +558,7 @@ other non-default provider fields are not supported"
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: Some(aws.unwrap_or(ModelProviderAwsAuthInfo {
|
||||
profile: None,
|
||||
region: None,
|
||||
@@ -727,6 +738,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api,
|
||||
query_params: None,
|
||||
|
||||
@@ -66,6 +66,7 @@ base_url = "http://localhost:11434/v1"
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -99,6 +100,7 @@ query_params = { api-version = "2025-04-01-preview" }
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: Some(maplit::hashmap! {
|
||||
@@ -136,6 +138,7 @@ supports_standalone_web_search = true
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
@@ -318,6 +321,7 @@ fn test_create_amazon_bedrock_provider() {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: Some(ModelProviderAwsAuthInfo {
|
||||
profile: None,
|
||||
region: None,
|
||||
|
||||
@@ -608,6 +608,7 @@ mod tests {
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
gateway_oauth: None,
|
||||
aws: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Decodes unknown error classifications as `Other` so enclosing records stay readable.
|
||||
//! The wire definition keeps serialization exhaustive and known payload validation strict.
|
||||
|
||||
use crate::protocol::CodexErrorInfo;
|
||||
use crate::protocol::NonSteerableTurnKind;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use serde::de::Error as _;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(
|
||||
remote = "CodexErrorInfo",
|
||||
rename = "CodexErrorInfo",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
enum CodexErrorInfoWire {
|
||||
ContextWindowExceeded,
|
||||
SessionBudgetExceeded,
|
||||
UsageLimitExceeded,
|
||||
RateLimitExceeded,
|
||||
ServerOverloaded,
|
||||
CyberPolicy,
|
||||
BioPolicy,
|
||||
MisalignmentPolicyViolation,
|
||||
HttpConnectionFailed {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
ResponseStreamConnectionFailed {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
InternalServerError,
|
||||
Unauthorized,
|
||||
BadRequest,
|
||||
SandboxError,
|
||||
ResponseStreamDisconnected {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
ResponseTooManyFailedAttempts {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind,
|
||||
},
|
||||
ThreadRollbackFailed,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Serialize for CodexErrorInfo {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
CodexErrorInfoWire::serialize(self, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CodexErrorInfo {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
// Probe the tag first so only unknown payloads are discarded: `serde(other)`
|
||||
// recognizes unknown tags, but its unit variant rejects their payloads.
|
||||
if let Value::Object(fields) = &value
|
||||
&& fields.len() == 1
|
||||
&& let Some(kind) = fields.keys().next()
|
||||
&& kind != "other"
|
||||
&& matches!(
|
||||
CodexErrorInfoWire::deserialize(Value::String(kind.clone())),
|
||||
Ok(Self::Other)
|
||||
)
|
||||
{
|
||||
return Ok(Self::Other);
|
||||
}
|
||||
CodexErrorInfoWire::deserialize(value).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub use tool_name::DEFAULT_FUNCTION_NAMESPACE;
|
||||
pub use tool_name::ToolName;
|
||||
pub mod approvals;
|
||||
pub mod capabilities;
|
||||
mod codex_error_info;
|
||||
pub mod config_types;
|
||||
pub mod dynamic_tools;
|
||||
mod environment;
|
||||
|
||||
@@ -1848,8 +1848,8 @@ pub enum NonSteerableTurnKind {
|
||||
}
|
||||
|
||||
/// Codex errors that we expose to clients.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[schemars(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
|
||||
@@ -73,6 +73,41 @@ fn rollout_line_decoder_preserves_canonical_json_compatibility() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_line_decoder_preserves_terminal_records_with_unknown_errors() -> Result<()> {
|
||||
let expected = serde_json::json!({
|
||||
"timestamp": "2025-01-03T12:00:00.000Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "task_complete",
|
||||
"turn_id": "turn-1",
|
||||
"last_agent_message": null,
|
||||
"error": {
|
||||
"message": "The request was blocked.",
|
||||
"codex_error_info": "other"
|
||||
},
|
||||
"started_at": 10,
|
||||
"completed_at": 20,
|
||||
"duration_ms": 10000
|
||||
}
|
||||
});
|
||||
for error_info in [
|
||||
serde_json::json!("future_error"),
|
||||
serde_json::json!({"future_error": {"detail": "new payload"}}),
|
||||
] {
|
||||
let mut encoded = expected.clone();
|
||||
encoded["payload"]["error"]["codex_error_info"] = error_info;
|
||||
let decoded = crate::decode_rollout_line(encoded)?;
|
||||
assert_eq!(serde_json::to_value(decoded)?, expected);
|
||||
}
|
||||
|
||||
let mut malformed = expected;
|
||||
malformed["payload"]["error"]["codex_error_info"] =
|
||||
serde_json::json!({"active_turn_not_steerable": {"turn_kind": "unknown"}});
|
||||
assert!(crate::decode_rollout_line(malformed).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider_vec(providers: &[&str]) -> Vec<String> {
|
||||
providers
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user