Enforce managed model provider selection and definitions (#44650)

## What changed

- Support `model_provider` and `model_providers` in managed requirements. Required selection overrides local and session configuration; each required provider definition replaces the corresponding local entry, including its authentication and headers.
- Merge provider requirement fragments before validation, preserving source-relative `auth.cwd` paths. Reject invalid cloud provider definitions before replacing the cached bundle, including unsupported Bedrock overrides.
- Expose `modelProvider` and `modelProviders` through the configuration requirements API and generated schemas. Reject writes to managed provider settings and omit their local origins, including for provider IDs containing dots.

## Testing

Add tests for requirement precedence, authentication fragment merging, path resolution, cloud cache preservation, and read-only configuration RPCs. Integration tests verify that model discovery and inference use the required gateway and ChatGPT authentication without leaking local headers, and that provider requirements survive configuration rebuilds.

GitOrigin-RevId: d1448604d4ef5662ebb5df7b693712d3b8ad6d54
This commit is contained in:
alexsong-oai
2026-09-10 21:41:39 +00:00
committed by copyberry
parent 60825b4988
commit 1aaa453ce2
34 changed files with 998 additions and 41 deletions
@@ -9997,6 +9997,21 @@
"null"
]
},
"modelProvider": {
"description": "Exact provider selection required by managed policy.",
"type": [
"string",
"null"
]
},
"modelProviders": {
"additionalProperties": true,
"description": "Complete required provider definitions, using config.toml field names.",
"type": [
"object",
"null"
]
},
"models": {
"anyOf": [
{
@@ -5877,6 +5877,21 @@
"null"
]
},
"modelProvider": {
"description": "Exact provider selection required by managed policy.",
"type": [
"string",
"null"
]
},
"modelProviders": {
"additionalProperties": true,
"description": "Complete required provider definitions, using config.toml field names.",
"type": [
"object",
"null"
]
},
"models": {
"anyOf": [
{
@@ -559,6 +559,21 @@
"null"
]
},
"modelProvider": {
"description": "Exact provider selection required by managed policy.",
"type": [
"string",
"null"
]
},
"modelProviders": {
"additionalProperties": true,
"description": "Complete required provider definitions, using config.toml field names.",
"type": [
"object",
"null"
]
},
"models": {
"anyOf": [
{
@@ -3,6 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PathUri } from "../PathUri";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { AutoReviewRequirements } from "./AutoReviewRequirements";
import type { BrowserUseRequirements } from "./BrowserUseRequirements";
@@ -15,4 +16,10 @@ import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, additionalDeveloperInstructions: string | null, allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowBrowserAndComputerUse: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null};
export type ConfigRequirements = {/**
* Exact provider selection required by managed policy.
*/
modelProvider: string | null, /**
* Complete required provider definitions, using config.toml field names.
*/
modelProviders: { [key in string]?: JsonValue } | null, cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, additionalDeveloperInstructions: string | null, allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowBrowserAndComputerUse: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null};
@@ -408,6 +408,10 @@ pub struct ConfigReadResponse {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigRequirements {
/// Exact provider selection required by managed policy.
pub model_provider: Option<String>,
/// Complete required provider definitions, using config.toml field names.
pub model_providers: Option<HashMap<String, JsonValue>>,
pub cli_auth_credentials_store: Option<CliAuthCredentialsStoreMode>,
pub chatgpt_base_url: Option<String>,
pub additional_developer_instructions: Option<String>,
@@ -2117,6 +2117,8 @@ fn config_approvals_reviewer_is_marked_experimental() {
fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() {
let reason =
crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements {
model_provider: None,
model_providers: None,
application: None,
cli_auth_credentials_store: None,
chatgpt_base_url: None,
@@ -148,17 +148,15 @@ impl ConfigManager {
let config: ApiConfig = serde_json::from_value(json_value)
.map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?;
let mut origins = layers.origins();
origins.retain(|path, metadata| {
if matches!(&metadata.name, ConfigLayerSource::PackagedDefaults { .. }) {
return false;
}
let segments = path.split('.').map(str::to_string).collect::<Vec<_>>();
let mut origins = layers.origins_with_path_filter(|segments| {
layers
.requirements_toml()
.exact_requirement_for_config_path(&segments)
.exact_requirement_for_config_path(segments)
.is_none()
});
origins.retain(|_, metadata| {
!matches!(&metadata.name, ConfigLayerSource::PackagedDefaults { .. })
});
Ok(ConfigReadResponse {
config,
@@ -388,6 +388,13 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.and_then(|windows| windows.sandbox_private_desktop);
ConfigRequirements {
model_provider: requirements.model_provider,
model_providers: requirements.model_providers.map(|providers| {
providers
.into_iter()
.map(|(id, provider)| (id, serde_json::json!(provider)))
.collect()
}),
application: requirements.application.map(|application| {
codex_app_server_protocol::ApplicationRequirements {
network: application.network.map(|network| {
@@ -0,0 +1,175 @@
//! Verifies provider requirements through the public configuration RPCs.
use anyhow::Result;
use app_test_support::TestAppServer;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_config::config_toml::ConfigToml;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn provider_requirements_are_effective_and_read_only() -> Result<()> {
let home = TempDir::new()?;
let requirements = r#"
model_provider = "gateway"
[model_providers.gateway]
name = "Managed gateway"
base_url = "https://gateway.example.test/v1"
requires_openai_auth = true
"#;
std::fs::write(home.path().join("requirements.toml"), requirements)?;
std::fs::write(
home.path().join("config.toml"),
r#"
model_provider = "openai"
[model_providers.gateway]
name = "Local override"
base_url = "https://local.example.test"
env_key = "LOCAL_KEY"
[model_providers.gateway.http_headers]
X-Local = "no"
"#,
)?;
let expected: ConfigToml = toml::from_str(requirements)?;
let expected_providers = json!(expected.model_providers);
let mut server = TestAppServer::builder()
.with_codex_home(home.path())
.build()
.await?;
timeout(Duration::from_secs(/*secs*/ 60), server.initialize()).await??;
let id = server.send_config_requirements_read_request().await?;
let response: ConfigRequirementsReadResponse =
timeout(Duration::from_secs(/*secs*/ 60), server.read_response(id)).await??;
let requirements = response
.requirements
.expect("provider requirements are visible");
assert_eq!(
(
requirements.model_provider,
json!(requirements.model_providers)
),
(Some("gateway".to_string()), expected_providers.clone())
);
let id = server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let response: ConfigReadResponse =
timeout(Duration::from_secs(/*secs*/ 60), server.read_response(id)).await??;
assert_eq!(
(
response.config.model_provider.as_deref(),
response.config.additional.get("model_providers")
),
(Some("gateway"), Some(&expected_providers))
);
assert!(
!response
.origins
.keys()
.any(|key| key == "model_provider" || key.starts_with("model_providers.gateway."))
);
let id = server
.send_thread_start_request_with_auto_env(ThreadStartParams {
model_provider: Some("openai".to_string()),
ephemeral: Some(true),
..Default::default()
})
.await?;
let started: ThreadStartResponse =
timeout(Duration::from_secs(/*secs*/ 60), server.read_response(id)).await??;
assert_eq!(started.model_provider, "gateway");
for (key, value) in [
("model_provider", json!("openai")),
(
"model_providers.gateway.base_url",
json!("https://other.example.test"),
),
("model_providers", json!({})),
] {
let id = server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: key.into(),
value,
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let error = timeout(
Duration::from_secs(/*secs*/ 60),
server.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(
error.error.data,
Some(json!({"config_write_error_code": "configRequirementReadonly"}))
);
}
let id = server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "model_providers.other".into(),
value: json!({"name": "Other provider"}),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let _: ConfigWriteResponse =
timeout(Duration::from_secs(/*secs*/ 60), server.read_response(id)).await??;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dotted_managed_provider_id_hides_exact_origins() -> Result<()> {
let home = TempDir::new()?;
std::fs::write(
home.path().join("requirements.toml"),
"[model_providers.\"corp.gateway\"]\nname = 'Managed gateway'\nbase_url = 'https://managed.example.test'\n",
)?;
std::fs::write(
home.path().join("config.toml"),
"[model_providers.\"corp.gateway\"]\nname = 'Local gateway'\nbase_url = 'https://local.example.test'\n[model_providers.corp]\nname = 'Other provider'\n",
)?;
let mut server = TestAppServer::builder()
.with_codex_home(home.path())
.build_initialized_with_timeout(Duration::from_secs(/*secs*/ 60))
.await?;
let id = server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let response: ConfigReadResponse =
timeout(Duration::from_secs(/*secs*/ 60), server.read_response(id)).await??;
assert_eq!(
response.config.additional["model_providers"]["corp.gateway"]["name"],
json!("Managed gateway")
);
assert!(
!response
.origins
.contains_key("model_providers.corp.gateway.name")
);
assert!(response.origins.contains_key("model_providers.corp.name"));
Ok(())
}
@@ -13,6 +13,8 @@ mod collaboration_mode_list;
#[cfg(unix)]
mod command_exec;
mod compaction;
#[path = "config_model_provider_requirements_tests.rs"]
mod config_model_provider_requirements;
mod config_requirements_application;
#[path = "config_requirements_browser_use_tests.rs"]
mod config_requirements_browser_use;
@@ -553,6 +553,55 @@ async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
);
}
#[tokio::test]
async fn invalid_cloud_provider_does_not_replace_cached_bundle() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let previous = test_bundle();
cache
.save(
Some("user-12345".to_string()),
Some("account-12345".to_string()),
previous.clone(),
)
.await
.expect("cache valid bundle");
for contents in [
"[model_providers.openai]\nname = 'Reserved'",
"[model_providers.gateway]\nname = ' '",
"[model_providers.gateway]\nbase_url = 'https://gateway.example/v1'",
"[model_providers.amazon-bedrock]\nname = 'Managed Bedrock'",
"[model_providers.amazon-bedrock-runtime]\nname = 'Managed Bedrock Runtime'",
"[model_providers.amazon-bedrock]\nrequest_max_retries = 3",
"[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.auth]\ntimeout_ms = 10000",
] {
let mut invalid = test_bundle();
invalid.requirements_toml.enterprise_managed[0].contents = contents.to_string();
let auth_manager = auth_manager_with_plan("business").await;
let auth = auth_manager.auth().await.expect("business auth");
let service = CloudConfigBundleService::new(
auth_manager,
Arc::new(StaticBundleClient::new(invalid.clone())),
codex_home.path().to_path_buf(),
CLOUD_CONFIG_BUNDLE_TIMEOUT,
);
let error = service
.validate_and_cache_remote_bundle(&auth, "refresh", /*attempt*/ 1, invalid)
.await
.expect_err("invalid provider must fail before cache write");
assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::InvalidBundle);
assert_eq!(
cache
.load(Some("user-12345"), Some("account-12345"))
.await
.expect("retain previous cache")
.bundle,
previous
);
}
}
#[tokio::test]
async fn get_bundle_ignores_invalid_cache_and_refetches() {
let codex_home = tempdir().expect("tempdir");
+15 -1
View File
@@ -4,6 +4,7 @@ use codex_config::CloudConfigBundleLayers;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_config::compose_requirements;
use codex_config::config_toml::validate_model_providers;
pub(crate) fn validate_bundle(
bundle: &CloudConfigBundle,
@@ -22,13 +23,26 @@ pub(crate) fn validate_bundle(
enterprise_managed_requirements,
} = bundle_layers;
compose_requirements(enterprise_managed_requirements).map_err(|err| {
let requirements = compose_requirements(enterprise_managed_requirements).map_err(|err| {
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::InvalidBundle,
/*status_code*/ None,
format!("invalid cloud config bundle: {err}"),
)
})?;
if let Some(providers) = requirements.and_then(|requirements| requirements.model_providers) {
validate_model_providers(&providers).map_err(|err| {
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::InvalidBundle,
/*status_code*/ None,
format!("invalid cloud config bundle: {err}"),
)
})?;
}
Ok(())
}
#[cfg(test)]
#[path = "validation_tests.rs"]
mod tests;
@@ -0,0 +1,51 @@
use super::*;
use codex_config::CloudRequirementsFragment;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn cloud_fragments_combine_before_provider_validation() {
let home = tempdir().expect("tempdir");
let base_dir = AbsolutePathBuf::from_absolute_path(home.path()).expect("absolute path");
let mut bundle = CloudConfigBundle::default();
// Cloud fragments arrive highest-priority first.
bundle.requirements_toml.enterprise_managed = vec![
CloudRequirementsFragment {
id: "high".to_string(),
name: "URL".to_string(),
contents: "[model_providers.gateway]\nbase_url = 'https://gateway.example/v1'\n[model_providers.gateway.auth]\ntimeout_ms = 10000\ncwd = 'auth'"
.to_string(),
},
CloudRequirementsFragment {
id: "low".to_string(),
name: "Name".to_string(),
contents: "[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.auth]\ncommand = 'get-token'".to_string(),
},
];
assert_eq!(validate_bundle(&bundle, &base_dir), Ok(()));
}
#[test]
fn cloud_bedrock_overrides_accept_supported_fields() {
let home = tempdir().expect("tempdir");
let base_dir = AbsolutePathBuf::from_absolute_path(home.path()).expect("absolute path");
for provider in ["amazon-bedrock", "amazon-bedrock-runtime"] {
let mut bundle = CloudConfigBundle::default();
bundle.requirements_toml.enterprise_managed = vec![CloudRequirementsFragment {
id: "bedrock".to_string(),
name: "Bedrock".to_string(),
contents: format!(
r#"
[model_providers.{provider}]
base_url = "https://bedrock.example"
[model_providers.{provider}.http_headers]
X-Managed = "required"
[model_providers.{provider}.aws]
profile = "managed"
region = "us-east-1"
"#
),
}];
assert_eq!(validate_bundle(&bundle, &base_dir), Ok(()));
}
}
+53 -1
View File
@@ -1,5 +1,6 @@
use crate::ApplicationRequirementsToml;
use codex_features::FeatureToml;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::SandboxMode;
@@ -15,6 +16,7 @@ use serde::de::value::Error as ValueDeserializerError;
use serde::de::value::StrDeserializer;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::convert::Infallible;
use std::fmt;
use std::path::PathBuf;
@@ -167,6 +169,8 @@ pub struct ConfigRequirements {
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub model_provider: Option<Sourced<String>>,
pub model_providers: Option<Sourced<HashMap<String, ModelProviderInfo>>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
@@ -209,6 +213,8 @@ impl Default for ConfigRequirements {
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
model_provider: None,
model_providers: None,
check_for_update_on_startup: None,
allow_login_shell: None,
feedback: None,
@@ -989,6 +995,10 @@ pub struct ConfigRequirementsToml {
pub sqlite_home: Option<AbsolutePathBuf>,
pub log_dir: Option<AbsolutePathBuf>,
pub model_catalog_json: Option<AbsolutePathBuf>,
/// Exact provider selection, overriding local and session configuration.
pub model_provider: Option<String>,
/// Complete provider definitions; each entry replaces the configured provider.
pub model_providers: Option<HashMap<String, ModelProviderInfo>>,
pub check_for_update_on_startup: Option<bool>,
pub allow_login_shell: Option<bool>,
pub feedback: Option<FeedbackConfigToml>,
@@ -1095,6 +1105,8 @@ pub struct ConfigRequirementsWithSources {
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub model_provider: Option<Sourced<String>>,
pub model_providers: Option<Sourced<HashMap<String, ModelProviderInfo>>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
@@ -1155,6 +1167,8 @@ impl ConfigRequirementsWithSources {
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
model_provider: _,
model_providers: _,
check_for_update_on_startup: _,
allow_login_shell: _,
feedback: _,
@@ -1210,6 +1224,8 @@ impl ConfigRequirementsWithSources {
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -1293,6 +1309,8 @@ impl ConfigRequirementsWithSources {
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -1334,6 +1352,8 @@ impl ConfigRequirementsWithSources {
sqlite_home: sqlite_home.map(|sourced| sourced.value),
log_dir: log_dir.map(|sourced| sourced.value),
model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
model_provider: model_provider.map(|sourced| sourced.value),
model_providers: model_providers.map(|sourced| sourced.value),
check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value),
allow_login_shell: allow_login_shell.map(|sourced| sourced.value),
feedback: feedback.map(|sourced| sourced.value),
@@ -1444,6 +1464,8 @@ impl ConfigRequirementsToml {
&& self.sqlite_home.is_none()
&& self.log_dir.is_none()
&& self.model_catalog_json.is_none()
&& self.model_provider.is_none()
&& self.model_providers.as_ref().is_none_or(HashMap::is_empty)
&& self.check_for_update_on_startup.is_none()
&& self.allow_login_shell.is_none()
&& self
@@ -1541,6 +1563,10 @@ impl ConfigRequirementsToml {
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
apply_exact!(model_provider);
if let Some(providers) = &self.model_providers {
config.model_providers.extend(providers.clone());
}
apply_exact!(check_for_update_on_startup);
apply_exact!(allow_login_shell);
@@ -1569,7 +1595,19 @@ impl ConfigRequirementsToml {
/// Returns the exact managed field affected by editing `segments`.
pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> {
let managed_fields: [(bool, &[&str], &'static str); 9] = [
if self.model_providers.as_ref().is_some_and(|providers| {
providers
.keys()
.any(|id| config_paths_overlap(segments, &["model_providers", id]))
}) {
return Some("model_providers");
}
let managed_fields: [(bool, &[&str], &'static str); 10] = [
(
self.model_provider.is_some(),
&["model_provider"],
"model_provider",
),
(self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"),
(self.log_dir.is_some(), &["log_dir"], "log_dir"),
(
@@ -1665,6 +1703,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -2029,6 +2069,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -2203,6 +2245,8 @@ mod tests {
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -2250,6 +2294,10 @@ mod tests {
log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_catalog_json: model_catalog_json
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_provider: model_provider
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_providers: model_providers
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
check_for_update_on_startup: check_for_update_on_startup
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_login_shell: allow_login_shell
@@ -2769,6 +2817,8 @@ mod tests {
sqlite_home: Some(sqlite_home.clone()),
log_dir: Some(log_dir.clone()),
model_catalog_json: Some(model_catalog_json.clone()),
model_provider: Some("gateway".to_string()),
model_providers: Some(HashMap::new()),
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(feedback.clone()),
@@ -2828,6 +2878,8 @@ mod tests {
sqlite_home: Some(Sourced::new(sqlite_home, source.clone())),
log_dir: Some(Sourced::new(log_dir, source.clone())),
model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())),
model_provider: Some(Sourced::new("gateway".to_string(), source.clone())),
model_providers: Some(Sourced::new(HashMap::new(), source.clone())),
check_for_update_on_startup: Some(Sourced::new(
/*value*/ false,
source.clone(),
+5 -1
View File
@@ -927,10 +927,14 @@ pub fn validate_model_providers(
) -> Result<(), String> {
validate_reserved_model_provider_ids(model_providers)?;
for (key, provider) in model_providers {
if !matches!(
if matches!(
key.as_str(),
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
) {
provider
.validate_bedrock_override()
.map_err(|message| format!("model_providers.{key} {message}"))?;
} else {
if provider.aws.is_some() {
return Err(format!(
"model_providers.{key}: provider aws is only supported for \
+6 -2
View File
@@ -11,24 +11,28 @@ pub(super) fn record_origins(
meta: &ConfigLayerMetadata,
path: &mut Vec<String>,
origins: &mut HashMap<String, ConfigLayerMetadata>,
include: &impl Fn(&[String]) -> bool,
) {
match value {
TomlValue::Table(table) => {
for (key, val) in table {
path.push(key.clone());
record_origins(val, meta, path, origins);
record_origins(val, meta, path, origins, include);
path.pop();
}
}
TomlValue::Array(items) => {
for (idx, item) in (0_i32..).zip(items.iter()) {
path.push(idx.to_string());
record_origins(item, meta, path, origins);
record_origins(item, meta, path, origins, include);
path.pop();
}
}
_ => {
if !path.is_empty() {
if !include(path) {
return;
}
if matches!(value, TomlValue::Boolean(_)) && is_structured_feature_path(path) {
if path
.last()
+1
View File
@@ -23,6 +23,7 @@ mod mcp_edit;
mod mcp_requirements;
mod mcp_types;
mod merge;
mod model_provider_requirements;
mod overrides;
pub mod permissions_toml;
mod plugin_edit;
@@ -0,0 +1,45 @@
//! Projects complete required provider definitions before config parsing.
//! Selection is applied with the other exact requirements after config parsing.
use crate::ConfigRequirementsToml;
use crate::config_toml::validate_model_providers;
use std::io;
use toml::Value;
pub(crate) fn to_config(requirements: &ConfigRequirementsToml) -> io::Result<Value> {
let mut config = toml::Table::new();
if let Some(providers) = &requirements.model_providers {
validate_model_providers(providers)
.map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))?;
config.insert(
"model_providers".to_string(),
Value::try_from(providers)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
);
}
Ok(Value::Table(config))
}
pub(crate) fn apply(config: &mut Value, requirements: &Value) {
let Some(config) = config.as_table_mut() else {
return;
};
if let Some(required) = requirements
.get("model_providers")
.and_then(Value::as_table)
{
let providers = config
.entry("model_providers")
.or_insert_with(|| Value::Table(Default::default()));
if !providers.is_table() {
*providers = Value::Table(Default::default());
}
if let Some(providers) = providers.as_table_mut() {
providers.extend(required.clone());
}
}
}
#[cfg(test)]
#[path = "model_provider_requirements_tests.rs"]
mod tests;
@@ -0,0 +1,99 @@
//! Tests exact provider requirements across layers and config projections.
use crate::ConfigLayerEntry;
use crate::ConfigLayerSource;
use crate::ConfigLayerStack;
use crate::ConfigRequirements;
use crate::ConfigRequirementsToml;
use crate::RequirementSource;
use crate::RequirementsLayerEntry;
use crate::compose_requirements;
use pretty_assertions::assert_eq;
const REQUIRED: &str = r#"
model_provider = "gateway"
[model_providers.gateway]
name = "Managed gateway"
base_url = "https://gateway.example.test/v1"
requires_openai_auth = true
[model_providers.gateway.http_headers]
X-Managed = "yes"
"#;
#[test]
fn higher_requirements_merge_provider_fields_and_nested_tables() -> anyhow::Result<()> {
let cloud_source = RequirementSource::EnterpriseManaged {
id: "req_1".into(),
name: "Gateway".into(),
};
let requirements = compose_requirements([
RequirementsLayerEntry::from_toml(
RequirementSource::Unknown,
r#"
model_provider = "other"
[model_providers.gateway]
name = "System gateway"
base_url = "https://old.example.test"
env_key = "OLD_KEY"
supports_websockets = true
[model_providers.gateway.http_headers]
X-Old = "no"
X-Managed = "old"
[model_providers.other]
name = "Other provider"
"#,
),
RequirementsLayerEntry::from_toml(cloud_source.clone(), REQUIRED),
])?
.expect("provider requirements must not be discarded");
let normalized = ConfigRequirements::try_from(requirements.clone())?;
assert_eq!(
normalized
.model_provider
.as_ref()
.map(|requirement| &requirement.source),
Some(&cloud_source)
);
let requirements = requirements.into_toml();
let expected: ConfigRequirementsToml = toml::from_str(
r#"
model_provider = "gateway"
[model_providers.gateway]
name = "Managed gateway"
base_url = "https://gateway.example.test/v1"
requires_openai_auth = true
env_key = "OLD_KEY"
supports_websockets = true
[model_providers.gateway.http_headers]
X-Managed = "yes"
X-Old = "no"
[model_providers.other]
name = "Other provider"
"#,
)?;
assert_eq!(requirements, expected);
assert_eq!(
normalized
.model_providers
.map(|requirement| requirement.value),
requirements.model_providers
);
Ok(())
}
#[test]
fn required_provider_definitions_are_validated_without_local_defaults() -> anyhow::Result<()> {
for invalid in [
"[model_providers.gateway]\nbase_url = 'https://example.test'",
"[model_providers.openai]\nname = 'Reserved'",
"[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.aws]\nregion = 'us-east-1'",
] {
let requirements = toml::from_str(invalid)?;
let local =
ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, toml::from_str(REQUIRED)?);
let error = ConfigLayerStack::new(vec![local], ConfigRequirements::default(), requirements)
.expect_err("local config cannot repair an invalid required provider");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
Ok(())
}
@@ -95,8 +95,33 @@ impl ComposableRequirementsLayer {
}
}
// Provider fragments can be incomplete until all requirements layers
// are merged. Resolve explicit paths while their source base is still
// available, without deserializing complete auth command objects.
if let Some(providers) = regular_toml
.get_mut("model_providers")
.and_then(TomlValue::as_table_mut)
{
for (id, provider) in providers.iter_mut() {
if let Some(cwd) = provider
.get_mut("auth")
.and_then(|auth| auth.get_mut("cwd"))
{
let resolved: AbsolutePathBuf =
cwd.clone().try_into().map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: format!("model_providers.{id}.auth.cwd: {err}"),
}
})?;
*cwd = toml_value_from_serializable(resolved)?;
}
}
}
let mut layer_requirements_toml = regular_toml.clone();
remove_top_level_field(&mut layer_requirements_toml, "model_providers");
let requirements = parse_layer_requirements(
&RequirementsLayerToml::Value(regular_toml.clone()),
&RequirementsLayerToml::Value(layer_requirements_toml),
&source,
)?;
(regular_toml, requirements)
@@ -218,6 +218,8 @@ fn populate_merged_regular_fields_with_sources(
sqlite_home,
log_dir,
model_catalog_json,
model_provider,
model_providers,
check_for_update_on_startup,
allow_login_shell,
feedback,
@@ -260,6 +262,8 @@ fn populate_merged_regular_fields_with_sources(
set_sourced!(sqlite_home, &["sqlite_home"]);
set_sourced!(log_dir, &["log_dir"]);
set_sourced!(model_catalog_json, &["model_catalog_json"]);
set_sourced!(model_provider, &["model_provider"]);
set_sourced!(model_providers, &["model_providers"]);
set_sourced!(
check_for_update_on_startup,
&["check_for_update_on_startup"]
@@ -271,6 +271,84 @@ fn relative_paths_resolve_against_their_own_layer_base() {
);
}
#[test]
fn provider_auth_fragments_merge_without_losing_source_paths_or_explicit_values() {
let low_dir = tempdir().expect("low-priority requirements directory");
let high_dir = tempdir().expect("high-priority requirements directory");
let absolute_cwd = toml::Value::String(low_dir.path().display().to_string()).to_string();
for (cwd_override, expected_cwd) in [
(String::new(), low_dir.path().join("auth")),
(
"cwd = 'other-auth'".to_string(),
high_dir.path().join("other-auth"),
),
(
format!("cwd = {absolute_cwd}"),
low_dir.path().to_path_buf(),
),
] {
let composed = compose(vec![
layer(
"low",
"Command",
r#"
[model_providers.gateway]
name = "Gateway"
[model_providers.gateway.auth]
command = "get-token"
args = ["--token"]
cwd = "auth"
timeout_ms = 7000
refresh_interval_ms = 12345
"#,
)
.with_base_dir(AbsolutePathBuf::from_absolute_path(low_dir.path()).unwrap()),
layer(
"high",
"Timeout",
&format!("[model_providers.gateway.auth]\ntimeout_ms = 10000\n{cwd_override}"),
)
.with_base_dir(AbsolutePathBuf::from_absolute_path(high_dir.path()).unwrap()),
])
.expect("merge partial auth before parsing")
.expect("requirements present");
let expected_cwd = toml::Value::String(expected_cwd.display().to_string());
assert_eq!(
composed,
expected_requirements(format!(
r#"
[model_providers.gateway]
name = "Gateway"
[model_providers.gateway.auth]
command = "get-token"
args = ["--token"]
cwd = {expected_cwd}
timeout_ms = 10000
refresh_interval_ms = 12345
"#,
))
);
}
}
#[test]
fn provider_auth_missing_command_is_rejected_after_composition() {
let err = compose(vec![
layer("low", "Name", "[model_providers.gateway]\nname = 'Gateway'"),
layer(
"high",
"Timeout",
"[model_providers.gateway.auth]\ntimeout_ms = 10000",
),
])
.expect_err("merged auth still needs a command");
assert!(matches!(
err,
RequirementsCompositionError::ComposedParse { message }
if message.contains("missing field `command`")
));
}
#[test]
fn composition_strategy_applies_to_non_cloud_layers() {
let mdm_source = RequirementSource::MdmManagedPreferences {
+37 -3
View File
@@ -245,6 +245,10 @@ impl ConfigLayerEntry {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigLayerStack {
/// Cached TOML projection derived only from `requirements_toml`.
/// Construction validates provider definitions and reports serialization errors,
/// so `effective_config()` can replace complete entries without a fallible conversion.
model_provider_requirements: Option<TomlValue>,
/// Layers are listed from lowest precedence (base) to highest (top), so
/// later entries in the Vec override earlier ones.
layers: Vec<ConfigLayerEntry>,
@@ -276,7 +280,11 @@ impl ConfigLayerStack {
) -> std::io::Result<Self> {
validate_enabled_config_layers(&layers)?;
verify_layer_ordering(&layers)?;
let model_provider_requirements = Some(crate::model_provider_requirements::to_config(
&requirements_toml,
)?);
Ok(Self {
model_provider_requirements,
layers,
requirements,
requirements_toml,
@@ -406,6 +414,7 @@ impl ConfigLayerStack {
}
Ok(Self {
layers,
model_provider_requirements: self.model_provider_requirements.clone(),
requirements: self.requirements.clone(),
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
@@ -440,6 +449,7 @@ impl ConfigLayerStack {
}
Self {
layers,
model_provider_requirements: self.model_provider_requirements.clone(),
requirements: self.requirements.clone(),
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
@@ -450,20 +460,37 @@ impl ConfigLayerStack {
/// Returns the merged config-layer view.
///
/// This only merges ordinary config layers. Requirements are composed and
/// tracked separately.
/// Required provider definitions replace local entries before deserialization.
/// Selection and other requirements are applied when constructing the final config.
pub fn effective_config(&self) -> TomlValue {
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in self.layers_low_to_high() {
merge_toml_values(&mut merged, &layer.config);
}
if let Some(requirements) = &self.model_provider_requirements {
crate::model_provider_requirements::apply(&mut merged, requirements);
}
merged
}
/// Required provider selection used when building the effective configuration.
pub fn required_model_provider(&self) -> Option<&str> {
self.requirements_toml.model_provider.as_deref()
}
/// Returns field origins for the merged config-layer view.
///
/// Requirement sources are tracked separately and are not included here.
pub fn origins(&self) -> HashMap<String, ConfigLayerMetadata> {
self.origins_with_path_filter(|_| true)
}
/// Filters origins using their original TOML key segments before formatting
/// them for the public API, where dots in quoted keys are ambiguous.
pub fn origins_with_path_filter(
&self,
include: impl Fn(&[String]) -> bool,
) -> HashMap<String, ConfigLayerMetadata> {
let mut origins = HashMap::new();
let mut path = Vec::new();
let mut provider_paths = vec!["features.network_proxy.credentials.".to_string()];
@@ -477,7 +504,13 @@ impl ConfigLayerStack {
.map(|name| format!("profiles.{name}.features.network_proxy.credentials.")),
);
}
record_origins(&config, &layer.metadata(), &mut path, &mut origins);
record_origins(
&config,
&layer.metadata(),
&mut path,
&mut origins,
&include,
);
}
if let Some(layer) = self.layers_low_to_high().next_back() {
@@ -488,6 +521,7 @@ impl ConfigLayerStack {
&layer.metadata(),
&mut path,
&mut effective_origins,
&include,
);
origins.retain(|path, _| {
!provider_paths.iter().any(|prefix| path.starts_with(prefix))
+6 -14
View File
@@ -1217,9 +1217,9 @@ command = "print-token"
assert_eq!(config.model_provider, expected_provider);
}
#[tokio::test]
async fn load_config_rejects_unsupported_amazon_bedrock_overrides() {
let cfg = toml::from_str::<ConfigToml>(
#[test]
fn config_toml_rejects_unsupported_amazon_bedrock_overrides() {
let err = toml::from_str::<ConfigToml>(
r#"
model_provider = "amazon-bedrock"
@@ -1229,17 +1229,7 @@ requires_openai_auth = true
supports_websockets = true
"#,
)
.expect("Amazon Bedrock unsupported overrides should deserialize");
let err = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
.expect_err("Amazon Bedrock unsupported overrides should fail validation");
assert!(err.to_string().contains(
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, and `aws.auth_refresh`; other non-default provider fields are not supported"
));
@@ -9997,6 +9987,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
let fixture = create_test_fixture()?;
let requirements_toml = codex_config::ConfigRequirementsToml {
model_provider: None,
model_providers: None,
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
cli_auth_credentials_store: None,
+4 -1
View File
@@ -3219,6 +3219,8 @@ impl Config {
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
model_provider: _,
model_providers: _,
check_for_update_on_startup: _,
allow_login_shell: _,
feedback: _,
@@ -3744,7 +3746,8 @@ impl Config {
merge_configured_model_providers(built_in_model_providers(openai_base_url), cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?;
let model_provider_id = model_provider
let model_provider_id = config_layer_stack.required_model_provider().map(str::to_string)
.or(model_provider)
.or(cfg.model_provider)
.unwrap_or_else(|| "openai".to_string());
let model_provider = model_providers
+4
View File
@@ -35,6 +35,10 @@ pub(super) fn apply_to_config(
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
apply_exact!(model_provider);
if let Some(providers) = &requirements.model_providers {
config.model_providers.extend(providers.value.clone());
}
apply_exact!(check_for_update_on_startup);
apply_exact!(allow_login_shell);
if requirements
+2
View File
@@ -110,6 +110,8 @@ mod mcp_tool_exposure;
mod mcp_turn_metadata;
mod mcp_user_verification;
mod model_overrides;
#[path = "model_provider_requirements_tests.rs"]
mod model_provider_requirements;
mod model_runtime_selectors;
mod model_switching;
mod model_visible_layout;
@@ -0,0 +1,219 @@
//! Exercises managed provider routing and conflict diagnostics through real turns.
use anyhow::Result;
use codex_config::LoaderOverrides;
use codex_config::config_toml::ConfigToml;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use test_case::test_case;
use wiremock::MockServer;
#[tokio::test]
async fn cloud_provider_auth_merges_before_parsing_and_resolves_cwd_from_codex_home() -> Result<()>
{
let home = tempdir()?;
std::fs::write(
home.path().join("config.toml"),
"[model_providers.gateway]\nname = 'Local'\nexperimental_bearer_token = 'local-token'",
)?;
// Cloud requirements arrive highest-priority first.
let managed = CloudConfigBundleFixture::enterprise_requirement(
"[model_providers.gateway.auth]\ntimeout_ms = 10000\ncwd = 'auth'",
)
.add_enterprise_requirement(
r#"
model_provider = "gateway"
[model_providers.gateway]
name = "Managed gateway"
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "get-token"
args = ["--token"]
refresh_interval_ms = 12345
"#,
)
.into_loader();
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.cloud_config_bundle(managed)
.build()
.await?;
let cwd = toml::Value::String(home.path().join("auth").display().to_string());
let expected: ConfigToml = toml::from_str(&format!(
r#"
[model_providers.gateway]
name = "Managed gateway"
base_url = "https://gateway.example/v1"
[model_providers.gateway.auth]
command = "get-token"
args = ["--token"]
timeout_ms = 10000
refresh_interval_ms = 12345
cwd = {cwd}
"#
))?;
assert_eq!(config.model_provider, expected.model_providers["gateway"]);
Ok(())
}
#[test_case("model_provider = 'gateway'", "other"; "selection_and_definition")]
#[test_case("", "gateway"; "definition_only")]
#[test_case("model_provider = 'gateway'", "matching"; "matching_configuration")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn required_provider_routes_models_and_inference_with_chatgpt_auth(
required_selection: &str,
local_selection: &str,
) -> Result<()> {
let gateway = MockServer::start().await;
let local = MockServer::start().await;
let gateway_url = format!("{}/v1", gateway.uri());
let local_url = format!("{}/v1", local.uri());
let local_config = format!(
r#"
model_provider = "{local_selection}"
[model_providers.gateway]
name = "Local gateway"
base_url = "{local_url}"
env_key = "CODEX_TEST_LOCAL_GATEWAY_KEY_MUST_NOT_BE_USED"
experimental_bearer_token = "local-token"
[model_providers.gateway.http_headers]
X-Local = "local"
[model_providers.other]
name = "Other provider"
base_url = "{local_url}"
"#
);
let requirements = format!(
r#"
{required_selection}
[model_providers.gateway]
name = "Managed gateway"
base_url = "{gateway_url}"
requires_openai_auth = true
[model_providers.gateway.http_headers]
X-Managed = "required"
"#
);
let local_config = if local_selection == "matching" {
requirements.clone()
} else {
local_config
};
let managed = CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements);
let models = mount_models_once(&gateway, bundled_models_response()?).await;
let responses = mount_sse_once(
&gateway,
sse(vec![
ev_response_created("response-1"),
ev_completed("response-1"),
]),
)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let expected_authorization = format!("Bearer {}", auth.get_token()?);
let test = test_codex()
.with_auth(auth)
.with_pre_build_hook(move |home| {
std::fs::write(home.join("config.toml"), local_config).expect("write local config");
})
.with_cloud_config_bundle(managed.clone())
.with_config(|config| {
// The fixture normally replaces the selected provider with its mock provider.
// Restore the provider entry selected by the real requirements-aware loader.
config.model_provider = config.model_providers[&config.model_provider_id].clone();
})
.build_with_auto_env(&local)
.await?;
let warnings = test
.config
.startup_warnings
.iter()
.filter(|warning| warning.contains("`model_provider"))
.cloned()
.collect::<Vec<_>>();
if local_selection == "other" {
insta::assert_debug_snapshot!("provider_selection_override_warning", warnings);
} else {
assert_eq!(warnings, Vec::<String>::new());
}
if !required_selection.is_empty() {
for cli_provider in ["openai", "gateway"] {
let overridden = ConfigBuilder::default()
.codex_home(test.config.codex_home.to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.cloud_config_bundle(managed.clone())
.cli_overrides(vec![(
"model_provider".into(),
toml::Value::String(cli_provider.into()),
)])
.harness_overrides(ConfigOverrides {
model_provider: Some("openai".into()),
..Default::default()
})
.build()
.await?;
let warnings = overridden
.startup_warnings
.iter()
.filter(|warning| warning.contains("`model_provider"))
.cloned()
.collect::<Vec<_>>();
if cli_provider == "gateway" {
assert_eq!(warnings, Vec::<String>::new());
} else {
insta::assert_debug_snapshot!("provider_selection_override_warning", warnings);
}
assert_eq!(&overridden.model_provider, &test.config.model_provider);
let rebuilt = overridden
.rebuild_preserving_session_layers(&overridden)
.await?;
assert_eq!(rebuilt.model_provider, test.config.model_provider);
}
}
test.submit_turn("Use the managed gateway.").await?;
let inference_request = responses.single_request();
assert_eq!(models.single_request_path(), "/v1/models");
assert_eq!(inference_request.path(), "/v1/responses");
let header_names = ["authorization", "x-managed", "x-local"];
let expected_headers = [
Some(expected_authorization),
Some("required".to_string()),
None,
];
assert_eq!(
header_names.map(|name| inference_request.header(name)),
expected_headers
);
for request in models.requests() {
let actual = header_names.map(|name| {
request
.headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
});
assert_eq!(actual, expected_headers);
}
assert!(
local
.received_requests()
.await
.expect("recorded local requests")
.is_empty()
);
Ok(())
}
@@ -0,0 +1,7 @@
---
source: core/tests/suite/model_provider_requirements_tests.rs
expression: warnings
---
[
"Configured value for `model_provider` is overridden by the required value \"gateway\" from enterprise-managed requirements Base requirements (req_1).",
]
+23 -9
View File
@@ -223,6 +223,26 @@ fn default_aws_auth_refresh_timeout_ms() -> NonZeroU64 {
}
impl ModelProviderInfo {
/// Checks that a configured Bedrock entry only customizes supported fields.
/// Call this on the override before merging it with the built-in provider.
pub fn validate_bedrock_override(&self) -> Result<(), String> {
let unsupported_fields = Self {
base_url: None,
auth: None,
aws: None,
http_headers: None,
..self.clone()
};
if unsupported_fields != Self::default() {
return Err("only supports changing \
`base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, \
and `aws.auth_refresh`; \
other non-default provider fields are not supported"
.to_string());
}
Ok(())
}
pub fn validate(&self) -> std::result::Result<(), String> {
if let Some(aws) = self.aws.as_ref() {
if self.supports_websockets {
@@ -615,19 +635,13 @@ pub fn merge_configured_model_providers(
key.as_str(),
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
) {
provider
.validate_bedrock_override()
.map_err(|message| format!("model_providers.{key} {message}"))?;
let base_url_override = provider.base_url.take();
let auth_override = provider.auth.take();
let aws_override = provider.aws.take();
let http_headers_override = provider.http_headers.take();
if provider != ModelProviderInfo::default() {
return Err(format!(
"model_providers.{key} only supports changing \
`base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, \
and `aws.auth_refresh`; \
other non-default provider fields are not supported"
));
}
if let Some(built_in_provider) = model_providers.get_mut(&key) {
built_in_provider.base_url = base_url_override;
built_in_provider.auth = auth_override;
+2
View File
@@ -963,6 +963,8 @@ interrupt_message = false
sqlite_home: Some(sqlite_home),
log_dir: Some(log_dir),
model_catalog_json: Some(model_catalog_json),
model_provider: None,
model_providers: None,
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(FeedbackConfigToml {
@@ -11309,6 +11309,20 @@ class ConfigRequirements(BaseModel):
in_app_browser: Annotated[InAppBrowserRequirements | None, Field(alias="inAppBrowser")] = None
log_dir: Annotated[str | None, Field(alias="logDir")] = None
model_catalog_json: Annotated[str | None, Field(alias="modelCatalogJson")] = None
model_provider: Annotated[
str | None,
Field(
alias="modelProvider",
description="Exact provider selection required by managed policy.",
),
] = None
model_providers: Annotated[
dict[str, Any] | None,
Field(
alias="modelProviders",
description="Complete required provider definitions, using config.toml field names.",
),
] = None
models: ModelsRequirements | None = None
sqlite_home: Annotated[str | None, Field(alias="sqliteHome")] = None
windows_sandbox_private_desktop: Annotated[