feat(core): route FedRAMP auth and model metadata

Propagate FedRAMP account routing headers through ChatGPT, cloud tasks, and model provider HTTP clients. Also retain newer protocol/model metadata fields such as custom tool output names, search support, and extra speed tiers.
This commit is contained in:
James Peter
2026-04-17 12:17:06 +10:00
parent 14d12fe743
commit 06a11b664e
17 changed files with 198 additions and 12 deletions
+12
View File
@@ -96,6 +96,7 @@ pub struct Client {
bearer_token: Option<String>,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
chatgpt_account_is_fedramp: bool,
path_style: PathStyle,
}
@@ -121,6 +122,7 @@ impl Client {
bearer_token: None,
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style,
})
}
@@ -142,6 +144,11 @@ impl Client {
self
}
pub fn with_fedramp_routing_header(mut self) -> Self {
self.chatgpt_account_is_fedramp = true;
self
}
pub fn with_path_style(mut self, style: PathStyle) -> Self {
self.path_style = style;
self
@@ -166,6 +173,11 @@ impl Client {
{
h.insert(name, hv);
}
if self.chatgpt_account_is_fedramp
&& let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp")
{
h.insert(name, HeaderValue::from_static("true"));
}
h
}
+7 -5
View File
@@ -25,14 +25,16 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
anyhow::anyhow!("ChatGPT account ID not available, please re-run `code login`")
});
let response = client
let mut request = client
.get(&url)
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.send()
.await
.context("Failed to send request")?;
.header("Content-Type", "application/json");
if token.id_token.is_fedramp_account() {
request = request.header("X-OpenAI-Fedramp", "true");
}
let response = request.send().await.context("Failed to send request")?;
if response.status().is_success() {
let result: T = response
+5
View File
@@ -47,6 +47,11 @@ impl HttpClient {
self
}
pub fn with_fedramp_routing_header(mut self) -> Self {
self.backend = self.backend.clone().with_fedramp_routing_header();
self
}
fn tasks_api(&self) -> api::Tasks<'_> {
api::Tasks::new(self)
}
+4
View File
@@ -225,6 +225,10 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option<PathBuf>) -> any
append_info_log(format!("auth: set ChatGPT-Account-Id header: {acc}"));
http = http.with_chatgpt_account_id(acc);
}
if auth.is_fedramp_account() {
append_info_log("auth: set X-OpenAI-Fedramp header: true".to_string());
http = http.with_fedramp_routing_header();
}
t
}
_ => {
+1
View File
@@ -265,6 +265,7 @@ mod tests {
id_token: IdTokenInfo {
email: Some(email.to_string()),
chatgpt_plan_type: None,
chatgpt_account_is_fedramp: false,
raw_jwt: fake_jwt(email, "pro"),
},
access_token: "access".to_string(),
+7
View File
@@ -249,6 +249,11 @@ impl CodexAuth {
.and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
}
pub fn is_fedramp_account(&self) -> bool {
self.get_current_token_data()
.is_some_and(|t| t.id_token.is_fedramp_account())
}
pub fn supports_pro_only_models(&self) -> bool {
self.mode.is_chatgpt()
&& self
@@ -1084,6 +1089,7 @@ mod tests {
id_token: IdTokenInfo {
email: Some("[email protected]".to_string()),
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
chatgpt_account_is_fedramp: false,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
@@ -1137,6 +1143,7 @@ mod tests {
id_token: IdTokenInfo {
email: Some("[email protected]".to_string()),
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
chatgpt_account_is_fedramp: false,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
+1
View File
@@ -380,6 +380,7 @@ mod tests {
id_token: IdTokenInfo {
email: email.map(|s| s.to_string()),
chatgpt_plan_type: None,
chatgpt_account_is_fedramp: false,
raw_jwt: fake_jwt(account_id, email, "pro"),
},
access_token: "access".to_string(),
+5 -1
View File
@@ -318,7 +318,11 @@ pub(crate) async fn stream_chat_completions(
});
push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
ResponseItem::CustomToolCallOutput {
call_id,
output,
..
} => {
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
+10 -2
View File
@@ -729,10 +729,18 @@ pub fn sanitize_items_for_compact(items: Vec<ResponseItem>) -> Vec<ResponseItem>
input,
})
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
ResponseItem::CustomToolCallOutput {
call_id,
name,
output,
} => {
let output = truncate_for_compact(output.to_string(), COMPACT_TOOL_OUTPUT_MAX_BYTES);
let output = FunctionCallOutputPayload::from_text(output);
Some(ResponseItem::CustomToolCallOutput { call_id, output })
Some(ResponseItem::CustomToolCallOutput {
call_id,
name,
output,
})
}
ResponseItem::Reasoning { id, summary, .. } => Some(ResponseItem::Reasoning {
id,
+7 -1
View File
@@ -2567,12 +2567,17 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
}
(
ResponseItem::CustomToolCall { .. },
Some(ResponseInputItem::CustomToolCallOutput { call_id, output }),
Some(ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
}),
) => {
items_to_record_in_conversation_history.push(item.clone());
items_to_record_in_conversation_history.push(
ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
name: name.clone(),
output: output.clone(),
},
);
@@ -3897,6 +3902,7 @@ async fn try_run_turn(
})
.map(|call_id| ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
name: None,
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
})
.collect::<Vec<_>>()
+10
View File
@@ -103,6 +103,12 @@ pub struct ModelFamily {
/// Responses API.
pub supports_parallel_tool_calls: bool,
/// Additional speed tiers advertised by the backend for this model.
pub additional_speed_tiers: Vec<String>,
/// Whether the backend says this model supports the native search tool.
pub supports_search_tool: bool,
/// Prefer websocket transport for this model when supported by the provider.
pub prefer_websockets: bool,
@@ -175,6 +181,8 @@ macro_rules! model_family {
default_reasoning_effort: None,
default_reasoning_summary: ReasoningSummary::Auto,
supports_parallel_tool_calls: false,
additional_speed_tiers: Vec::new(),
supports_search_tool: false,
prefer_websockets: false,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
@@ -460,6 +468,8 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily {
default_reasoning_effort: None,
default_reasoning_summary: ReasoningSummary::Auto,
supports_parallel_tool_calls: false,
additional_speed_tiers: Vec::new(),
supports_search_tool: false,
prefer_websockets: false,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
+9
View File
@@ -307,6 +307,9 @@ impl ModelProviderInfo {
if let Some(auth) = auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
if auth.is_fedramp_account() {
builder = builder.header("X-OpenAI-Fedramp", "true");
}
}
Ok(self.apply_http_headers(builder))
@@ -339,6 +342,9 @@ impl ModelProviderInfo {
if let Some(auth) = auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
if auth.is_fedramp_account() {
builder = builder.header("X-OpenAI-Fedramp", "true");
}
}
Ok(self.apply_http_headers(builder))
@@ -373,6 +379,9 @@ impl ModelProviderInfo {
let mut builder = client.post(url);
if let Some(auth) = auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
if auth.is_fedramp_account() {
builder = builder.header("X-OpenAI-Fedramp", "true");
}
}
Ok(self.apply_http_headers(builder))
+4
View File
@@ -424,6 +424,8 @@ fn apply_model_info_overrides_with_personality(
.experimental_supported_tools
.iter()
.any(|tool| tool == IMAGE_GENERATION_TOOL);
family.additional_speed_tiers = info.additional_speed_tiers.clone();
family.supports_search_tool = info.supports_search_tool;
if let Some(limit) = info.auto_compact_token_limit() {
family.set_auto_compact_token_limit(Some(limit));
@@ -463,6 +465,7 @@ mod tests {
visibility: ModelVisibility::None,
supported_in_api: true,
priority: 0,
additional_speed_tiers: Vec::new(),
availability_nux: None,
upgrade: None,
base_instructions: String::new(),
@@ -481,6 +484,7 @@ mod tests {
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
supports_search_tool: false,
prefer_websockets: false,
used_fallback_model_metadata: false,
}
+49
View File
@@ -42,6 +42,7 @@ pub struct IdTokenInfo {
/// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
/// (Note: values may vary by backend.)
pub(crate) chatgpt_plan_type: Option<PlanType>,
pub(crate) chatgpt_account_is_fedramp: bool,
pub raw_jwt: String,
}
@@ -52,6 +53,10 @@ impl IdTokenInfo {
PlanType::Unknown(s) => s.clone(),
})
}
pub fn is_fedramp_account(&self) -> bool {
self.chatgpt_account_is_fedramp
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -94,6 +99,8 @@ struct IdClaims {
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<PlanType>,
#[serde(default)]
chatgpt_account_is_fedramp: bool,
}
#[derive(Deserialize)]
@@ -143,6 +150,11 @@ pub fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
tracing::debug!(
email = claims.email.as_deref().unwrap_or("<missing>"),
chatgpt_plan_type = plan.as_deref().unwrap_or("unknown"),
chatgpt_account_is_fedramp = claims
.auth
.as_ref()
.map(|auth| auth.chatgpt_account_is_fedramp)
.unwrap_or(false),
"decoded ChatGPT id_token claims"
);
}
@@ -150,6 +162,10 @@ pub fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
Ok(IdTokenInfo {
email,
chatgpt_account_is_fedramp: auth
.as_ref()
.map(|claims| claims.chatgpt_account_is_fedramp)
.unwrap_or(false),
chatgpt_plan_type: auth.and_then(|a| a.chatgpt_plan_type),
raw_jwt: id_token.to_string(),
})
@@ -205,6 +221,7 @@ mod tests {
let info = parse_id_token(&fake_jwt).expect("should parse");
assert_eq!(info.email.as_deref(), Some("[email protected]"));
assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro"));
assert!(!info.is_fedramp_account());
}
#[test]
@@ -232,6 +249,38 @@ mod tests {
let info = parse_id_token(&fake_jwt).expect("should parse");
assert!(info.email.is_none());
assert!(info.get_chatgpt_plan_type().is_none());
assert!(!info.is_fedramp_account());
}
#[test]
fn id_token_info_parses_fedramp_flag() {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_is_fedramp": true
}
});
fn b64url_no_pad(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
let signature_b64 = b64url_no_pad(b"sig");
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
let info = parse_id_token(&fake_jwt).expect("should parse");
assert!(info.is_fedramp_account());
}
#[test]
+16 -3
View File
@@ -209,6 +209,9 @@ pub enum ResponseInputItem {
},
CustomToolCallOutput {
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
name: Option<String>,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
@@ -342,6 +345,9 @@ pub enum ResponseItem {
},
CustomToolCallOutput {
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
name: Option<String>,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
@@ -854,9 +860,15 @@ impl From<ResponseInputItem> for ResponseItem {
};
Self::FunctionCallOutput { call_id, output }
}
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
Self::CustomToolCallOutput { call_id, output }
}
ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
} => Self::CustomToolCallOutput {
call_id,
name,
output,
},
ResponseInputItem::ToolSearchOutput {
call_id,
status,
@@ -1677,6 +1689,7 @@ mod tests {
fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
let item = ResponseInputItem::CustomToolCallOutput {
call_id: "call1".into(),
name: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
+45
View File
@@ -4,6 +4,7 @@
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::str::FromStr;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -19,6 +20,7 @@ use crate::config_types::ReasoningSummary;
use crate::config_types::Verbosity;
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
pub const SPEED_TIER_FAST: &str = "fast";
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
@@ -48,6 +50,15 @@ pub enum ReasoningEffort {
XHigh,
}
impl FromStr for ReasoningEffort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.map_err(|_| format!("invalid reasoning_effort: {s}"))
}
}
/// Canonical user-input modality tags advertised by a model.
#[derive(
Debug,
@@ -122,6 +133,9 @@ pub struct ModelPreset {
/// Whether this model supports personality-specific instructions.
#[serde(default)]
pub supports_personality: bool,
/// Additional speed tiers this model can run with beyond the standard path.
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
@@ -242,6 +256,8 @@ pub struct ModelInfo {
pub visibility: ModelVisibility,
pub supported_in_api: bool,
pub priority: i32,
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
@@ -274,6 +290,8 @@ pub struct ModelInfo {
/// Input modalities accepted by the backend for this model.
#[serde(default = "default_input_modalities")]
pub input_modalities: Vec<InputModality>,
#[serde(default)]
pub supports_search_tool: bool,
/// When true, this model should use websocket transport even when websocket features are off.
#[serde(default)]
pub prefer_websockets: bool,
@@ -419,6 +437,7 @@ impl From<ModelInfo> for ModelPreset {
.unwrap_or(ReasoningEffort::None),
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
supports_personality,
additional_speed_tiers: info.additional_speed_tiers,
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
@@ -440,6 +459,12 @@ impl From<ModelInfo> for ModelPreset {
}
impl ModelPreset {
pub fn supports_fast_mode(&self) -> bool {
self.additional_speed_tiers
.iter()
.any(|tier| tier == SPEED_TIER_FAST)
}
/// Filter models based on authentication mode.
///
/// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
@@ -518,6 +543,7 @@ mod tests {
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
@@ -536,6 +562,7 @@ mod tests {
effective_context_window_percent: 95,
experimental_supported_tools: vec![],
input_modalities: default_input_modalities(),
supports_search_tool: false,
prefer_websockets: false,
used_fallback_model_metadata: false,
}
@@ -743,12 +770,14 @@ mod tests {
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"],
"supports_search_tool": false,
"prefer_websockets": false
}))
.expect("deserialize model info");
assert_eq!(model.availability_nux, None);
assert!(!model.supports_image_detail_original);
assert!(!model.supports_search_tool);
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
}
@@ -758,6 +787,7 @@ mod tests {
availability_nux: Some(ModelAvailabilityNux {
message: "Try Spark.".to_string(),
}),
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
..test_model(None)
});
@@ -767,5 +797,20 @@ mod tests {
message: "Try Spark.".to_string(),
})
);
assert!(preset.supports_fast_mode());
}
#[test]
fn reasoning_effort_from_str_accepts_known_values() {
assert_eq!("high".parse(), Ok(ReasoningEffort::High));
assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal));
}
#[test]
fn reasoning_effort_from_str_rejects_unknown_values() {
assert_eq!(
"unsupported".parse::<ReasoningEffort>(),
Err("invalid reasoning_effort: unsupported".to_string())
);
}
}
+6
View File
@@ -32,6 +32,7 @@ struct CloudTasksConfig {
base_url: String,
token: Option<String>,
account_id: Option<String>,
is_fedramp_account: bool,
use_mock: bool,
}
@@ -196,6 +197,9 @@ fn build_backend(config: &CloudTasksConfig) -> Result<Arc<dyn CloudBackend>> {
if let Some(account) = &config.account_id {
client = client.with_chatgpt_account_id(account.clone());
}
if config.is_fedramp_account {
client = client.with_fedramp_routing_header();
}
Ok(Arc::new(client))
}
@@ -212,6 +216,7 @@ async fn load_config() -> Result<CloudTasksConfig> {
base_url,
token: None,
account_id: None,
is_fedramp_account: false,
use_mock,
});
}
@@ -240,6 +245,7 @@ async fn load_config() -> Result<CloudTasksConfig> {
base_url,
token: Some(token),
account_id,
is_fedramp_account: auth.is_fedramp_account(),
use_mock: false,
})
}