Add capability overrides for custom model providers (#50459)

## What changed

Allow Responses-compatible providers to configure live web access and remote compaction under `model_providers.<id>.capabilities`:

```toml
[model_providers.custom.capabilities]
external_web_access = false
remote_compaction = "v2"
```

`external_web_access = false` keeps hosted web search from accessing the live web. `remote_compaction` accepts `v2` to send `compaction_trigger` items through the Responses endpoint, or `unsupported` to use local compaction. Omitted capabilities retain existing provider defaults.

## Testing

Add unit coverage for preserving unspecified defaults and a request-snapshot scenario verifying cached web search and V2 compaction for a custom provider.

GitOrigin-RevId: fbcc4870aa51c112af575d711679a3941d1e7878
This commit is contained in:
Celia Chen
2026-10-02 23:26:27 +00:00
committed by copyberry
parent 820f85cf59
commit c6049ebd96
21 changed files with 368 additions and 50 deletions
@@ -612,6 +612,7 @@ mod thread_processor_behavior_tests {
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
let config_manager = ConfigManager::new(
+1
View File
@@ -316,6 +316,7 @@ mod tests {
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: true,
capabilities: None,
include_internal_metadata: false,
}
}
@@ -194,6 +194,7 @@ fn model_provider_from_proto(
requires_openai_auth: provider.requires_openai_auth,
supports_websockets: provider.supports_websockets,
supports_standalone_web_search: provider.supports_standalone_web_search,
capabilities: None,
include_internal_metadata: false,
};
Ok((id, info))
@@ -226,6 +227,7 @@ fn model_provider_to_proto(
supports_websockets,
supports_standalone_web_search,
include_internal_metadata: _,
capabilities: _,
} = provider;
proto::ModelProvider {
@@ -581,6 +583,7 @@ mod tests {
supports_standalone_web_search: true,
gateway_oauth: None,
aws: None,
capabilities: None,
include_internal_metadata: false,
}
}
+46
View File
@@ -2693,6 +2693,25 @@
},
"type": "object"
},
"ModelProviderCapabilities": {
"additionalProperties": false,
"description": "Overrides for the optional API features supported by a custom provider.",
"properties": {
"external_web_access": {
"description": "Whether hosted web search may access the live web.",
"type": "boolean"
},
"remote_compaction": {
"allOf": [
{
"$ref": "#/definitions/RemoteCompactionSupport"
}
],
"description": "Remote context-compaction protocol; omission preserves provider defaults."
}
},
"type": "object"
},
"ModelProviderInfo": {
"additionalProperties": false,
"description": "Serializable representation of a provider definition.",
@@ -2717,6 +2736,14 @@
"description": "Base URL for the provider's OpenAI-compatible API.",
"type": "string"
},
"capabilities": {
"allOf": [
{
"$ref": "#/definitions/ModelProviderCapabilities"
}
],
"description": "Optional API capability overrides for a custom Responses-compatible provider. Unspecified capabilities retain their existing provider defaults."
},
"env_http_headers": {
"additionalProperties": {
"type": "string"
@@ -3919,6 +3946,25 @@
}
]
},
"RemoteCompactionSupport": {
"description": "Remote context-compaction protocols supported by a model provider.",
"oneOf": [
{
"description": "Use local compaction instead of a provider compaction protocol.",
"enum": [
"unsupported"
],
"type": "string"
},
{
"description": "Send `compaction_trigger` items over the Responses endpoint.",
"enum": [
"v2"
],
"type": "string"
}
]
},
"RemoteMessageBoardConfigToml": {
"additionalProperties": false,
"description": "Connection to a board provisioned by the research host.",
+3
View File
@@ -92,6 +92,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -235,6 +236,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -359,6 +361,7 @@ async fn responses_respects_model_info_overrides_from_config() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
+4
View File
@@ -1642,6 +1642,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -3161,6 +3162,7 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids(
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -3799,6 +3801,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -3886,6 +3889,7 @@ async fn env_var_overrides_loaded_auth() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -2661,6 +2661,7 @@ fn websocket_provider_with_connect_timeout(
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
}
}
+3
View File
@@ -123,6 +123,9 @@ mod preparation;
#[path = "scenarios_content_filter.rs"]
mod content_filter;
#[path = "scenarios_provider_capabilities.rs"]
mod provider_capabilities;
#[path = "scenarios_shared_instructions.rs"]
mod shared_instructions;
@@ -0,0 +1,100 @@
//! Custom provider overrides constrain web access and enable V2 compaction.
use super::*;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn custom_provider_capabilities_constrain_web_access_and_enable_compaction() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"
model_provider = "custom"
web_search = "cached"
[features]
standalone_web_search = false
[features.multi_agent_v2]
enabled = true
tool_namespace = "collaboration"
expose_spawn_agent_model_overrides = false
[model_providers.custom]
name = "Custom"
requires_openai_auth = true
[model_providers.custom.capabilities]
external_web_access = false
remote_compaction = "v2"
"#,
)?;
let test = test_codex()
.with_home(home)
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
configure_scenario_catalog(config);
let base_url = config.model_provider.base_url.clone();
// Restore the configured provider after the test builder installs its default.
config.model_provider = config.model_providers["custom"].clone();
config.model_provider.base_url = base_url;
})
.with_model_info_override("gpt-5.5", |model| {
model.use_responses_lite = false;
model.tool_mode = Some(ToolMode::Direct);
model.apply_patch_tool_type = None;
})
.build_with_auto_env(&server)
.await?;
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![ev_completed("response")]),
sse(vec![
json!({
"type": "response.output_item.done",
"item": {"type": "compaction", "encrypted_content": "CUSTOM_SUMMARY"},
}),
ev_completed("compact-response"),
]),
],
)
.await;
test.submit_turn_with_permission_profile(
"Check the configuration.",
PermissionProfile::Disabled,
)
.await?;
test.codex.submit(Op::Compact).await?;
wait_for_event(&test.codex, |event| {
assert!(!matches!(event, EventMsg::Error(_)), "{event:?}");
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = mock.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].inputs_of_type("compaction_trigger").len(), 1);
let body = requests[0].body_json();
let tools = body["tools"].as_array().expect("request tools");
assert!(
requests[0]
.tool_by_name("collaboration", "list_agents")
.is_some()
);
let web_search = tools
.iter()
.find(|tool| tool["type"] == "web_search")
.expect("cached search");
assert_eq!(web_search["external_web_access"], false);
insta::assert_snapshot!(
"custom_provider_capabilities",
context_snapshot::format_request_history_snapshot(
"Custom provider overrides keep unrestricted search cached and enable V2 compaction.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
.include_request_settings(),
)
);
Ok(())
}
@@ -0,0 +1,60 @@
---
source: core/tests/suite/scenarios_provider_capabilities.rs
expression: "context_snapshot::format_request_history_snapshot(\"Custom provider overrides keep unrestricted search cached and enable V2 compaction.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: Custom provider overrides keep unrestricted search cached and enable V2 compaction.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
instructions: <MODEL_INSTRUCTIONS>
model: "gpt-5.5"
parallel_tool_calls: true
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
tools (6; hash=3b8fa9a9ca9788e3):
- function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5
- function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2
- function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6
- function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=3b8ac3e34b9c55bb
- function/followup_task
- function/interrupt_agent
- function/list_agents
- function/send_message
- function/spawn_agent
- function/wait_agent
- web_search; hash=f103e327c6870a46
-- request 1 (turn) --
00:message/developer:
<PERMISSIONS_INSTRUCTIONS>
01:message/developer:
You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
Child agents can also spawn their own sub-agents.
<OMITTED 14 LINES; ~204 TOKENS; hash=f7be62db50c2fa5f>
All agents share the same directory. In detail:
- All agents have access to the same container and filesystem as you.
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
02:message/developer:
<MULTI_AGENT_MODE>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Check the configuration.
-- request 2 (compaction) --
05:compaction_trigger
@@ -84,6 +84,7 @@ async fn continue_after_stream_error() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -70,6 +70,7 @@ async fn retries_on_early_close() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
+1
View File
@@ -79,6 +79,7 @@ mod tests {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -0,0 +1,28 @@
//! Serializable capability overrides for custom Responses-compatible providers.
//!
//! Unspecified capabilities inherit the provider's existing defaults. Model and
//! turn settings may further restrict the capabilities declared here.
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
/// Overrides for the optional API features supported by a custom provider.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ModelProviderCapabilities {
/// Whether hosted web search may access the live web.
pub external_web_access: Option<bool>,
/// Remote context-compaction protocol; omission preserves provider defaults.
pub remote_compaction: Option<RemoteCompactionSupport>,
}
/// Remote context-compaction protocols supported by a model provider.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RemoteCompactionSupport {
/// Use local compaction instead of a provider compaction protocol.
Unsupported,
/// Send `compaction_trigger` items over the Responses endpoint.
V2,
}
+9
View File
@@ -31,7 +31,10 @@ use std::sync::PoisonError;
use std::sync::RwLock;
use std::time::Duration;
mod capabilities;
mod gateway_oauth;
pub use capabilities::ModelProviderCapabilities;
pub use capabilities::RemoteCompactionSupport;
pub use gateway_oauth::GatewayOAuthConfig;
pub use gateway_oauth::GatewayOAuthDelivery;
@@ -161,6 +164,9 @@ pub struct ModelProviderInfo {
/// Which wire protocol this provider expects.
#[serde(default)]
pub wire_api: WireApi,
/// Optional API capability overrides for a custom Responses-compatible provider.
/// Unspecified capabilities retain their existing provider defaults.
pub capabilities: Option<ModelProviderCapabilities>,
/// Optional query parameters to append to the base URL.
pub query_params: Option<HashMap<String, RedactedString>>,
/// Additional HTTP headers to include in requests to this provider where
@@ -554,6 +560,7 @@ other non-default provider fields are not supported"
requires_openai_auth: true,
supports_websockets: true,
supports_standalone_web_search: true,
capabilities: None,
include_internal_metadata: true,
}
}
@@ -593,6 +600,7 @@ other non-default provider fields are not supported"
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
}
}
@@ -766,6 +774,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
}
}
@@ -96,6 +96,7 @@ base_url = "http://localhost:11434/v1"
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -134,6 +135,7 @@ query_params = { api-version = "2025-04-01-preview" }
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
};
@@ -176,6 +178,7 @@ supports_standalone_web_search = true
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: true,
capabilities: None,
include_internal_metadata: false,
};
@@ -365,6 +368,7 @@ fn test_create_amazon_bedrock_provider() {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
}
);
@@ -30,6 +30,8 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::openai_models::ModelsResponse;
use crate::ProviderCapabilities;
use crate::RemoteCompactionSupport;
use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth as resolve_configured_provider_auth;
use crate::provider::ModelProvider;
@@ -37,9 +39,7 @@ use crate::provider::ModelProviderFuture;
use crate::provider::ProviderAccountResult;
use crate::provider::ProviderAccountState;
use crate::provider::ProviderAuthRecoveryMessages;
use crate::provider::ProviderCapabilities;
use crate::provider::ProviderUnauthorizedRecovery;
use crate::provider::RemoteCompactionSupport;
use crate::shared_state::process_shared_state;
use auth::resolve_provider_auth as resolve_bedrock_provider_auth;
pub(crate) use auth_refresh::AwsAuthRecovery;
@@ -0,0 +1,57 @@
//! Provider capability defaults and resolution of custom provider overrides.
use codex_api::is_azure_responses_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::RemoteCompactionSupport;
/// Optional provider-backed features that Codex may expose at runtime.
///
/// These capabilities are a provider-owned upper bound. Model and turn settings
/// can further restrict the functionality exposed to the model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCapabilities {
pub image_generation: bool,
pub web_search: bool,
pub external_web_access: bool,
pub remote_compaction: RemoteCompactionSupport,
}
impl Default for ProviderCapabilities {
fn default() -> Self {
Self {
image_generation: true,
web_search: true,
external_web_access: true,
remote_compaction: RemoteCompactionSupport::Unsupported,
}
}
}
impl ProviderCapabilities {
pub(crate) fn from_config(info: &ModelProviderInfo) -> Self {
let defaults = Self {
remote_compaction: if info.is_openai()
|| is_azure_responses_provider(&info.name, info.base_url.as_deref())
{
RemoteCompactionSupport::V2
} else {
RemoteCompactionSupport::Unsupported
},
..Self::default()
};
let overrides = info.capabilities.unwrap_or_default();
Self {
external_web_access: overrides
.external_web_access
.unwrap_or(defaults.external_web_access),
remote_compaction: overrides
.remote_compaction
.unwrap_or(defaults.remote_compaction),
..defaults
}
}
}
#[cfg(test)]
#[path = "capabilities_tests.rs"]
mod tests;
@@ -0,0 +1,35 @@
use super::*;
use crate::create_model_provider;
use codex_model_provider_info::ModelProviderCapabilities;
use pretty_assertions::assert_eq;
#[test]
fn capability_overrides_preserve_unspecified_provider_defaults() {
for info in [
ModelProviderInfo::default(),
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
ModelProviderInfo {
name: "Azure".to_string(),
..Default::default()
},
] {
let defaults = create_model_provider(info.clone(), /*auth_manager*/ None).capabilities();
let provider = create_model_provider(
ModelProviderInfo {
capabilities: Some(ModelProviderCapabilities {
external_web_access: Some(false),
..Default::default()
}),
..info
},
/*auth_manager*/ None,
);
assert_eq!(
provider.capabilities(),
ProviderCapabilities {
external_web_access: false,
..defaults
},
);
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
mod amazon_bedrock;
mod auth;
mod bearer_auth_provider;
mod capabilities;
mod combined_auth;
mod models_endpoint;
mod models_identity;
@@ -24,9 +25,11 @@ pub use auth::auth_provider_from_auth_manager;
pub use auth::unauthenticated_auth_provider;
pub use bearer_auth_provider::BearerAuthProvider;
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
pub use capabilities::ProviderCapabilities;
pub use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
pub use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID;
pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
pub use codex_model_provider_info::RemoteCompactionSupport;
pub use codex_protocol::account::ProviderAccount;
pub use provider::ModelProvider;
pub use provider::ModelProviderFuture;
@@ -34,9 +37,7 @@ pub use provider::ProviderAccountError;
pub use provider::ProviderAccountResult;
pub use provider::ProviderAccountState;
pub use provider::ProviderAuthRecoveryMessages;
pub use provider::ProviderCapabilities;
pub use provider::ProviderUnauthorizedRecovery;
pub use provider::RemoteCompactionSupport;
pub use provider::SharedModelProvider;
pub use provider::create_model_provider;
+5 -46
View File
@@ -8,7 +8,6 @@ use codex_api::ApiError;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_api::TransportError;
use codex_api::is_azure_responses_provider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::GatewayAuthManager;
@@ -23,6 +22,9 @@ use codex_protocol::account::ProviderAccount;
use codex_protocol::error::CodexErr;
use codex_protocol::openai_models::ModelsResponse;
use crate::ProviderCapabilities;
#[cfg(test)]
use crate::RemoteCompactionSupport;
use crate::ResolvedResponsesProvider;
use crate::amazon_bedrock::AmazonBedrockModelProvider;
use crate::auth::ProviderAuthScope;
@@ -34,39 +36,6 @@ use crate::combined_auth::compose_auth;
use crate::models_endpoint::OpenAiModelsEndpoint;
use crate::workspace_routing::WorkspaceRoutingContext;
/// Remote context-compaction protocols supported by a model provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteCompactionSupport {
/// The provider does not support remote compaction.
Unsupported,
/// The provider supports `compaction_trigger` items over the Responses endpoint.
V2,
}
/// Optional provider-backed features that Codex may expose at runtime.
///
/// These capabilities are a provider-owned upper bound. Callers can disable
/// more functionality through normal config, but should not expose a feature
/// that the active provider marks unsupported here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCapabilities {
pub image_generation: bool,
pub web_search: bool,
pub external_web_access: bool,
pub remote_compaction: RemoteCompactionSupport,
}
impl Default for ProviderCapabilities {
fn default() -> Self {
Self {
image_generation: true,
web_search: true,
external_web_access: true,
remote_compaction: RemoteCompactionSupport::Unsupported,
}
}
}
/// Current app-visible account state for a model provider.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountState {
@@ -457,18 +426,7 @@ impl ModelProvider for ConfiguredModelProvider {
}
fn capabilities(&self) -> ProviderCapabilities {
let remote_compaction = if self.info.is_openai()
|| is_azure_responses_provider(&self.info.name, self.info.base_url.as_deref())
{
RemoteCompactionSupport::V2
} else {
RemoteCompactionSupport::Unsupported
};
ProviderCapabilities {
remote_compaction,
..ProviderCapabilities::default()
}
ProviderCapabilities::from_config(&self.info)
}
fn approval_review_preferred_model(&self) -> &'static str {
@@ -696,6 +654,7 @@ mod tests {
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
include_internal_metadata: false,
}
}