Add a built-in Google Cloud Vertex AI provider (#56265)

## What changed

- Support `model_provider = "gcp"` with a Vertex AI endpoint derived from `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`, defaulting to the `global` location.
- Obtain credentials through `gcloud auth application-default print-access-token` by default, and allow `base_url`, `auth`, and `http_headers` overrides under `model_providers.gcp`.
- Use a static model catalog tailored to Vertex AI, preserve function tools, namespaces, and client-side tool search, and support remote compaction V2. Disable unsupported reasoning summaries, Responses Lite, custom tools, web search, and image generation.
- Expose a `gcp` account type through the app-server protocol and generated clients without resolving credentials during account reads or requiring OpenAI authentication.

## Testing

Add coverage for endpoint resolution, configuration overrides, model catalog filtering, authenticated requests, unsupported request fields, missing credentials, account reporting across provider changes, and automatic remote compaction.

GitOrigin-RevId: 97a3158269e6dbc2605e2cf576998e09817ea12a
This commit is contained in:
Celia Chen
2026-10-22 21:01:14 +00:00
committed by copyberry
parent 61a991990f
commit 3fd148a20c
32 changed files with 1329 additions and 60 deletions
@@ -6834,6 +6834,23 @@
],
"title": "AmazonBedrockAccount",
"type": "object"
},
{
"description": "Google Cloud Vertex AI with externally managed credentials.",
"properties": {
"type": {
"enum": [
"gcp"
],
"title": "GcpAccountType",
"type": "string"
}
},
"required": [
"type"
],
"title": "GcpAccount",
"type": "object"
}
]
},
@@ -69,6 +69,23 @@
],
"title": "AmazonBedrockAccount",
"type": "object"
},
{
"description": "Google Cloud Vertex AI with externally managed credentials.",
"properties": {
"type": {
"enum": [
"gcp"
],
"title": "GcpAccountType",
"type": "string"
}
},
"required": [
"type"
],
"title": "GcpAccount",
"type": "object"
}
]
},
@@ -65,6 +65,23 @@
],
"title": "AmazonBedrockAccount",
"type": "object"
},
{
"description": "Google Cloud Vertex AI with externally managed credentials.",
"properties": {
"type": {
"enum": [
"gcp"
],
"title": "GcpAccountType",
"type": "string"
}
},
"required": [
"type"
],
"title": "GcpAccount",
"type": "object"
}
]
},
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PlanType } from "../PlanType";
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", usesCodexManagedCredentials: boolean, };
export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", usesCodexManagedCredentials: boolean, } | { "type": "gcp", };
@@ -41,6 +41,11 @@ pub enum Account {
#[serde(default)]
uses_codex_managed_credentials: bool,
},
/// Google Cloud Vertex AI with externally managed credentials.
#[serde(rename = "gcp", rename_all = "camelCase")]
#[ts(rename = "gcp", rename_all = "camelCase")]
Gcp {},
}
impl From<ProviderAccount> for Account {
@@ -53,6 +58,7 @@ impl From<ProviderAccount> for Account {
} => Self::AmazonBedrock {
uses_codex_managed_credentials,
},
ProviderAccount::Gcp => Self::Gcp {},
}
}
}
@@ -0,0 +1,100 @@
//! GCP account reporting follows the selected provider without resolving its credentials.
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::GetAccountParams;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 30);
#[test_case(""; "application_default_credentials")]
#[test_case(
r#"
[model_providers.gcp]
base_url = "https://vertex.example.com/v1"
[model_providers.gcp.auth]
command = "codex-test-gcp-command-that-does-not-exist"
"#;
"external_command_and_endpoint"
)]
#[tokio::test]
async fn gcp_account_is_reported_without_resolving_credentials(overrides: &str) -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!("model_provider = \"gcp\"\n{overrides}"),
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
for refresh_token in [false, true] {
let request_id = app_server
.send_get_account_request(GetAccountParams { refresh_token })
.await?;
let received: Value = timeout(READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(
received,
json!({"account": {"type": "gcp"}, "requiresOpenaiAuth": false, "workspaceRouting": null})
);
}
Ok(())
}
#[tokio::test]
async fn gcp_account_tracks_provider_changes_with_saved_chatgpt_credentials() -> Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join("config.toml");
std::fs::write(&config_path, "model_provider = \"gcp\"\n")?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
for (provider, expected) in [
(
"gcp",
json!({"account": {"type": "gcp"}, "requiresOpenaiAuth": false, "workspaceRouting": null}),
),
(
"amazon-bedrock",
json!({"account": {"type": "amazonBedrock", "usesCodexManagedCredentials": false}, "requiresOpenaiAuth": false, "workspaceRouting": null}),
),
(
"openai",
json!({"account": {"type": "chatgpt", "email": "user@example.com", "planType": "pro"}, "requiresOpenaiAuth": true, "workspaceRouting": null}),
),
(
"gcp",
json!({"account": {"type": "gcp"}, "requiresOpenaiAuth": false, "workspaceRouting": null}),
),
] {
std::fs::write(&config_path, format!("model_provider = \"{provider}\"\n"))?;
let request_id = app_server
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let received: Value = timeout(READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(received, expected);
}
Ok(())
}
@@ -1,6 +1,8 @@
mod account;
#[path = "account_system_proxy_tests.rs"]
mod account_system_proxy;
mod account_gcp;
mod account_thread_usage;
mod agent_message_board;
mod analytics;
+43 -13
View File
@@ -35,6 +35,7 @@ use crate::types::WindowsToml;
use codex_features::FeaturesToml;
use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID;
use codex_model_provider_info::GCP_PROVIDER_ID;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
@@ -64,9 +65,10 @@ use serde::Serialize;
use serde::de::Error as SerdeError;
use serde_json::Value as JsonValue;
const RESERVED_MODEL_PROVIDER_IDS: [&str; 5] = [
const RESERVED_MODEL_PROVIDER_IDS: [&str; 6] = [
AMAZON_BEDROCK_PROVIDER_ID,
AMAZON_BEDROCK_RUNTIME_PROVIDER_ID,
GCP_PROVIDER_ID,
OPENAI_PROVIDER_ID,
OLLAMA_OSS_PROVIDER_ID,
LMSTUDIO_OSS_PROVIDER_ID,
@@ -936,7 +938,7 @@ pub fn validate_reserved_model_provider_ids(
.filter(|key| {
!matches!(
key.as_str(),
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID | GCP_PROVIDER_ID
) && RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())
})
.map(|key| format!("`{key}`"))
@@ -965,18 +967,20 @@ pub fn validate_model_providers(
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 \
} else if provider.aws.is_some() {
return Err(format!(
"model_providers.{key}: provider aws is only supported for \
`{AMAZON_BEDROCK_PROVIDER_ID}` or `{AMAZON_BEDROCK_RUNTIME_PROVIDER_ID}`"
));
}
if provider.name.trim().is_empty() {
return Err(format!(
"model_providers.{key}: provider name must not be empty"
));
}
));
}
if !matches!(
key.as_str(),
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID | GCP_PROVIDER_ID
) && provider.name.trim().is_empty()
{
return Err(format!(
"model_providers.{key}: provider name must not be empty"
));
}
provider
.validate()
@@ -1125,4 +1129,30 @@ command = " "
)
);
}
#[test]
fn gcp_provider_accepts_partial_endpoint_and_auth_overrides() {
let config = toml::from_str::<ConfigToml>(
r#"
[model_providers.gcp]
base_url = "https://vertex.example.com/v1"
[model_providers.gcp.auth]
command = "print-token"
"#,
)
.expect("partial GCP provider overrides should deserialize");
let provider = config
.model_providers
.get(GCP_PROVIDER_ID)
.expect("GCP overrides should be retained");
assert_eq!(
(
provider.base_url.as_deref(),
provider.auth.as_ref().map(|auth| auth.command.as_str()),
),
(Some("https://vertex.example.com/v1"), Some("print-token"))
);
}
}
+2 -1
View File
@@ -964,7 +964,8 @@ impl ModelClient {
effort: effort
.or_else(|| model_info.default_reasoning_level.clone())
.map(|effort| model_info.resolve_reasoning_effort(effort)),
summary: (model_info.supports_reasoning_summary_parameter
summary: (self.state.provider.capabilities().reasoning_summaries
&& model_info.supports_reasoning_summary_parameter
&& summary != ReasoningSummaryConfig::None)
.then_some(summary),
// When Responses Lite is disabled, omit context so Responses uses the default,
+102
View File
@@ -1254,6 +1254,108 @@ command = "print-token"
assert_eq!(config.model_provider, expected_provider);
}
#[tokio::test]
async fn load_config_uses_resolved_cwd_for_default_gcp_auth() {
let codex_home = tempdir().expect("tempdir");
let workspace = tempdir().expect("tempdir");
let cwd = workspace.abs();
for base_url in [None, Some("https://vertex.example.com/v1")] {
let mut cfg = ConfigToml {
model_provider: Some("gcp".to_string()),
..Default::default()
};
if let Some(base_url) = base_url {
cfg.model_providers.insert(
"gcp".to_string(),
ModelProviderInfo {
base_url: Some(base_url.to_string()),
..Default::default()
},
);
}
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides {
cwd: Some(cwd.to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await
.expect("load config");
let mut expected_provider = ModelProviderInfo::create_gcp_provider();
expected_provider.base_url = base_url.map(str::to_string);
expected_provider.auth = Some(ModelProviderAuthInfo {
command: if cfg!(windows) {
"gcloud.cmd"
} else {
"gcloud"
}
.to_string(),
args: vec![
"auth".into(),
"application-default".into(),
"print-access-token".into(),
],
timeout_ms: std::num::NonZeroU64::new(30_000).expect("timeout should be non-zero"),
refresh_interval_ms: 3_000_000,
cwd: cwd.clone(),
});
assert_eq!(config.model_provider_id, "gcp");
assert_eq!(config.model_provider, expected_provider);
assert_eq!(config.model_providers.get("gcp"), Some(&expected_provider));
}
}
#[tokio::test]
async fn load_config_applies_gcp_transport_overrides() {
let cfg = toml::from_str::<ConfigToml>(
r#"
model_provider = "gcp"
[model_providers.gcp]
base_url = "https://vertex.example.com/v1"
http_headers = { "X-Custom-Header" = "value" }
[model_providers.gcp.auth]
command = "print-token"
"#,
)
.expect("GCP transport overrides should deserialize");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
let mut expected_provider = built_in_model_providers(/*openai_base_url*/ None)
.remove("gcp")
.expect("GCP provider should be built in");
expected_provider.base_url = Some("https://vertex.example.com/v1".to_string());
expected_provider.auth = Some(ModelProviderAuthInfo {
command: "print-token".to_string(),
args: Vec::new(),
timeout_ms: std::num::NonZeroU64::new(5_000).expect("timeout should be non-zero"),
refresh_interval_ms: 300_000,
cwd: std::env::current_dir()
.expect("current directory should be available")
.try_into()
.expect("current directory should be absolute"),
});
expected_provider.http_headers = Some(std::collections::HashMap::from([(
"X-Custom-Header".to_string(),
"value".into(),
)]));
assert_eq!(config.model_provider_id, "gcp");
assert_eq!(config.model_provider, expected_provider);
}
#[test]
fn config_toml_rejects_unsupported_amazon_bedrock_overrides() {
let err = toml::from_str::<ConfigToml>(
+20 -1
View File
@@ -90,6 +90,7 @@ use codex_mcp::McpProtocolMode;
use codex_mcp::McpServerRegistration;
use codex_mcp::ResolvedMcpCatalog;
use codex_model_provider::ProviderCapabilities;
use codex_model_provider_info::GCP_PROVIDER_ID;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
@@ -101,6 +102,7 @@ use codex_protocol::MemoryVersion;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
@@ -3983,10 +3985,27 @@ impl Config {
.clone()
.filter(|value| !value.is_empty());
let model_providers =
let mut model_providers =
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))?;
if let Some(provider) = model_providers.get_mut(GCP_PROVIDER_ID)
&& provider.auth.is_none()
{
// Registry construction must not depend on the process cwd, which may be deleted.
provider.auth = Some(ModelProviderAuthInfo {
command: if cfg!(windows) { "gcloud.cmd" } else { "gcloud" }.to_string(),
args: vec![
"auth".into(),
"application-default".into(),
"print-access-token".into(),
],
timeout_ms: std::num::NonZeroU64::new(30_000).unwrap_or(std::num::NonZeroU64::MIN),
refresh_interval_ms: 3_000_000,
cwd: resolved_cwd.clone(),
});
}
let model_provider_id = config_layer_stack.required_model_provider().map(str::to_string)
.or(model_provider)
.or(cfg.model_provider)
+3 -3
View File
@@ -871,14 +871,14 @@ fn write_auth_json(
fake_jwt
}
struct ProviderAuthCommandFixture {
pub(super) struct ProviderAuthCommandFixture {
tempdir: TempDir,
command: String,
args: Vec<String>,
}
impl ProviderAuthCommandFixture {
fn new(tokens: &[&str]) -> std::io::Result<Self> {
pub(super) fn new(tokens: &[&str]) -> std::io::Result<Self> {
let tempdir = tempfile::tempdir()?;
let tokens_file = tempdir.path().join("tokens.txt");
let mut token_file_contents = String::new();
@@ -942,7 +942,7 @@ move /y tokens.next tokens.txt >nul
})
}
fn auth(&self) -> ModelProviderAuthInfo {
pub(super) fn auth(&self) -> ModelProviderAuthInfo {
ModelProviderAuthInfo {
command: self.command.clone(),
args: self.args.iter().cloned().map(Into::into).collect(),
+80 -29
View File
@@ -15,8 +15,10 @@ use codex_login::CodexAuth;
use codex_login::auth::BedrockApiKeyAuth;
use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_5_MODEL_ID;
use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
use codex_model_provider_info::GCP_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ImageReference;
use codex_protocol::models::ResponseItem;
@@ -51,6 +53,8 @@ use test_case::test_case;
use tokio::time::Duration;
use wiremock::ResponseTemplate;
use super::client::ProviderAuthCommandFixture;
#[path = "compact_remote_trimming.rs"]
mod trimming;
@@ -534,18 +538,62 @@ async fn remote_compact_v2_records_usage_before_output_validation() -> Result<()
Ok(())
}
enum CompactionProvider {
AmazonBedrock,
Gcp,
}
#[test_case(CompactionProvider::AmazonBedrock; "bedrock")]
#[test_case(CompactionProvider::Gcp; "gcp")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn amazon_bedrock_automatic_compaction_uses_v2_responses_endpoint() -> Result<()> {
async fn provider_automatic_compaction_uses_v2_responses_endpoint(
provider: CompactionProvider,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_auto_env_builder(amazon_bedrock_test_codex().with_config(
|config| {
let server = responses::start_mock_server().await;
let auth_fixture;
let (builder, expected_model, expected_path, expected_auth) = match provider {
CompactionProvider::AmazonBedrock => (
amazon_bedrock_test_codex(),
AMAZON_BEDROCK_GPT_5_5_MODEL_ID,
"/v1/responses",
"Bearer bedrock-test-api-key",
),
CompactionProvider::Gcp => {
auth_fixture = ProviderAuthCommandFixture::new(&["vertex-access-token"])?;
let mut provider = ModelProviderInfo::create_gcp_provider();
provider.base_url = Some(format!(
"{}/v1/projects/example-project/locations/global/publishers/openai/v1",
server.uri()
));
provider.auth = Some(auth_fixture.auth());
(
test_codex()
.with_model("gpt-6.1-sol")
.with_config(move |config| {
config.model_provider_id = GCP_PROVIDER_ID.to_string();
config.model_provider = provider;
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
config
.features
.enable(Feature::RemoteCompactionV2)
.expect("test config should allow feature update");
}),
"gpt-6.1-sol",
"/v1/projects/example-project/locations/global/publishers/openai/v1/responses",
"Bearer vertex-access-token",
)
}
};
let test = builder
.with_config(|config| {
config.model_auto_compact_token_limit = Some(200);
},
))
.await?;
})
.build_with_auto_env(&server)
.await?;
let response_mock = responses::mount_sse_sequence(
harness.server(),
&server,
vec![
sse(vec![
responses::ev_assistant_message("message-1", "before automatic compaction"),
@@ -556,7 +604,7 @@ async fn amazon_bedrock_automatic_compaction_uses_v2_responses_endpoint() -> Res
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "BEDROCK_AUTOMATIC_REMOTE_COMPACTED_SUMMARY",
"encrypted_content": "PROVIDER_AUTOMATIC_REMOTE_COMPACTED_SUMMARY",
}
}),
responses::ev_completed("response-compact"),
@@ -569,44 +617,47 @@ async fn amazon_bedrock_automatic_compaction_uses_v2_responses_endpoint() -> Res
)
.await;
harness
.test()
.submit_turn("before automatic compact")
.await?;
harness
.test()
.submit_turn("after automatic compact")
.await?;
test.submit_turn("before automatic compact").await?;
test.submit_turn("after automatic compact").await?;
let response_requests = response_mock.requests();
assert_eq!(response_requests.len(), 3);
assert!(
response_requests
.iter()
.all(|request| request.path() == "/v1/responses")
.all(|request| request.path() == expected_path)
);
let compact_request = &response_requests[1];
assert_eq!(
compact_request.header("authorization").as_deref(),
Some("Bearer bedrock-test-api-key")
);
assert_eq!(
compact_request
.header("x-amzn-mantle-client-agent")
.as_deref(),
Some("codex")
);
assert_eq!(
compact_request.body_json()["model"],
AMAZON_BEDROCK_GPT_5_5_MODEL_ID
Some(expected_auth)
);
match provider {
CompactionProvider::AmazonBedrock => assert_eq!(
compact_request
.header("x-amzn-mantle-client-agent")
.as_deref(),
Some("codex")
),
CompactionProvider::Gcp => {
for request in &response_requests {
assert_eq!(request.body_json()["reasoning"].get("summary"), None);
assert_eq!(
request.header("x-openai-internal-codex-responses-lite"),
None
);
assert!(request.inputs_of_type("additional_tools").is_empty());
}
}
}
assert_eq!(compact_request.body_json()["model"], expected_model);
assert_eq!(
compact_request.inputs_of_type("compaction_trigger").len(),
1
);
assert!(response_requests[2].input().iter().any(|item| {
item["type"] == "compaction"
&& item["encrypted_content"] == "BEDROCK_AUTOMATIC_REMOTE_COMPACTED_SUMMARY"
&& item["encrypted_content"] == "PROVIDER_AUTOMATIC_REMOTE_COMPACTED_SUMMARY"
}));
Ok(())
+355
View File
@@ -0,0 +1,355 @@
use anyhow::Context;
use anyhow::Result;
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_features::Feature;
use codex_model_provider_info::GCP_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use test_case::test_case;
use super::client::ProviderAuthCommandFixture;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn vertex_requests_use_command_auth_with_namespaces_and_tool_search() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let auth_fixture =
ProviderAuthCommandFixture::new(&["vertex-access-token", "vertex-access-token"])?;
let mut provider = ModelProviderInfo::create_gcp_provider();
provider.base_url = Some(format!(
"{}/v1/projects/example-project/locations/global/publishers/openai/v1",
server.uri()
));
provider.auth = Some(auth_fixture.auth());
let mut test = test_codex()
.with_model_info_override("gpt-6.1-sol", |model| {
model.use_responses_lite = true;
model.tool_mode = Some(ToolMode::CodeModeOnly);
model.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform);
model.supports_search_tool = true;
model.multi_agent_version = Some(MultiAgentVersion::V2);
})
.with_config(move |config| {
config.model_provider_id = GCP_PROVIDER_ID.to_string();
config.model_provider = provider;
config.base_instructions = Some("Vertex test instructions".to_string());
})
.build_with_auto_env(&server)
.await?;
let input_schema = json!({
"type": "object",
"properties": {},
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "records".to_string(),
description: "Record tools.".to_string(),
tools: vec![
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: "status".to_string(),
description: "Get record service status.".to_string(),
input_schema: input_schema.clone(),
defer_loading: false,
}),
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: "lookup".to_string(),
description: "Look up a record.".to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
}),
],
});
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
dynamic_tools: vec![dynamic_tool],
..test.start_thread_options().await
})
.await?;
test.codex = thread.thread;
test.session_configured = thread.session_configured;
test.submit_turn("Look up a record").await?;
let request = response.single_request();
assert_eq!(
request.path(),
"/v1/projects/example-project/locations/global/publishers/openai/v1/responses"
);
assert_eq!(
request.header("authorization").as_deref(),
Some("Bearer vertex-access-token")
);
assert_eq!(
request.header("x-openai-internal-codex-responses-lite"),
None
);
assert!(
request.inputs_of_type("additional_tools").is_empty(),
"Vertex request must omit Responses Lite additional_tools inputs"
);
let body = request.body_json();
assert_eq!(body["model"], "gpt-6.1-sol");
assert_eq!(request.instructions_text(), "Vertex test instructions");
let tools = body["tools"]
.as_array()
.context("Vertex request should include model tools")?;
assert_eq!(
test.codex.multi_agent_version(),
Some(MultiAgentVersion::V2)
);
assert!(tools.iter().any(|tool| tool["name"] == "collaboration"));
let namespace = tools.iter().find(|tool| tool["name"] == "records");
assert_eq!(
namespace,
Some(&json!({
"type": "namespace",
"name": "records",
"description": "Record tools.",
"tools": [{
"type": "function",
"name": "status",
"description": "Get record service status.",
"strict": false,
"parameters": input_schema,
}],
}))
);
assert!(
tools
.iter()
.any(|tool| tool["type"] == "tool_search" && tool["execution"] == "client"),
"Vertex request should expose client-side tool search: {tools:?}"
);
assert!(
tools
.iter()
.any(|tool| tool.get("name").and_then(Value::as_str) == Some("exec_command")),
"Vertex request should preserve the exec_command function tool: {tools:?}"
);
Ok(())
}
#[test_case("gpt-6.1-sol"; "catalog model")]
#[test_case("vertex-test-model"; "fallback model")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn vertex_requests_omit_reasoning_summaries(model: &str) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let auth_fixture = ProviderAuthCommandFixture::new(&["vertex-access-token"])?;
let mut provider = ModelProviderInfo::create_gcp_provider();
provider.base_url = Some(format!("{}/v1", server.uri()));
provider.auth = Some(auth_fixture.auth());
let test = test_codex()
.with_model_info_override("gpt-6.1-sol", |model_info| {
model_info.supports_reasoning_summary_parameter = true;
model_info.default_reasoning_summary = ReasoningSummary::Detailed;
})
.with_model(model)
.with_config(move |config| {
config.model_provider_id = GCP_PROVIDER_ID.to_string();
config.model_provider = provider;
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_reasoning_summary = Some(ReasoningSummary::None);
config
.features
.enable(Feature::ConcurrentReasoningSummaries)
.expect("test config should allow feature update");
})
.build_with_auto_env(&server)
.await?;
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "say hello".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
summary: Some(ReasoningSummary::Concise),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let body = response.single_request().body_json();
assert_eq!(body["model"], model);
assert_eq!(body["reasoning"], json!({"effort": "high"}));
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
assert_eq!(body.get("stream_options"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_vertex_credentials_explain_how_to_reauthenticate() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("unused-response"),
responses::ev_completed("unused-response"),
]),
)
.await;
let auth_fixture = ProviderAuthCommandFixture::new(&[])?;
let mut provider = ModelProviderInfo::create_gcp_provider();
provider.base_url = Some(format!(
"{}/v1/projects/example-project/locations/global/publishers/openai/v1",
server.uri()
));
provider.auth = Some(auth_fixture.auth());
let test = test_codex()
.with_model("vertex-test-model")
.with_config(move |config| {
config.model_provider_id = GCP_PROVIDER_ID.to_string();
config.model_provider = provider;
})
.build_with_auto_env(&server)
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "say hello".into(),
text_elements: Vec::new(),
}]))
.await?;
let EventMsg::Error(error) =
wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await
else {
unreachable!();
};
assert_eq!(
error.message,
"Google Cloud credentials are unavailable or expired. Run \
`gcloud auth application-default login` to reauthenticate."
);
assert!(response.requests().is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn vertex_guardian_requests_use_luna() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("parent-tool"),
responses::ev_function_call(
"reviewed-tool",
"exec_command",
r#"{"cmd":"true","sandbox_permissions":"require_escalated","justification":"test"}"#,
),
responses::ev_completed("parent-tool"),
]),
responses::sse(vec![
responses::ev_response_created("guardian-review"),
responses::ev_assistant_message("guardian", r#"{"outcome":"deny"}"#),
responses::ev_completed("guardian-review"),
]),
responses::sse(vec![responses::ev_completed("parent-complete")]),
],
)
.await;
let auth_fixture =
ProviderAuthCommandFixture::new(&["vertex-access-token", "vertex-access-token"])?;
let mut provider = ModelProviderInfo::create_gcp_provider();
provider.base_url = Some(format!("{}/v1", server.uri()));
provider.auth = Some(auth_fixture.auth());
let test = test_codex()
.with_model("gpt-6.1-sol")
.with_config(move |config| {
config.model_provider_id = GCP_PROVIDER_ID.to_string();
config.model_provider = provider;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
})
.build_with_auto_env(&server)
.await?;
let models = test.thread_manager.get_models_manager().try_list_models()?;
insta::assert_debug_snapshot!(
"gcp_model_picker",
models
.iter()
.map(|model| (&model.model, model.show_in_picker, model.is_default))
.collect::<Vec<_>>()
);
test.submit_text_turn("run a command").await?;
let requests = response.requests();
assert_eq!(
requests
.iter()
.map(|request| request.body_json()["model"].clone())
.collect::<Vec<_>>(),
vec![
json!("gpt-6.1-sol"),
json!("gpt-6-luna"),
json!("gpt-6.1-sol")
]
);
let guardian_request = &requests[1];
assert_eq!(guardian_request.path(), "/v1/responses");
assert_eq!(
guardian_request.header("authorization").as_deref(),
Some("Bearer vertex-access-token")
);
Ok(())
}
+1
View File
@@ -88,6 +88,7 @@ mod exec_policy;
mod extension_sandbox;
mod external_auth;
mod fork_thread;
mod gcp;
mod git_enrichment;
mod guardian_authorization;
mod guardian_authorization_refresh;
@@ -0,0 +1,21 @@
---
source: core/tests/suite/gcp.rs
expression: "models.iter().map(|model|\n(&model.model, model.show_in_picker, model.is_default)).collect::<Vec<_>>()"
---
[
(
"gpt-6.1-sol",
true,
true,
),
(
"gpt-6-astra",
true,
false,
),
(
"gpt-6-luna",
true,
false,
),
]
+1 -1
View File
@@ -73,7 +73,7 @@ pub(crate) async fn program_for_turn(
Some(Account::ApiKey { .. }) => {
features.enabled(Feature::ApiKeyCyberAccessPrograms)
}
Some(Account::AmazonBedrock { .. }) | None => false,
Some(Account::AmazonBedrock { .. } | Account::Gcp {}) | None => false,
};
return Ok(eligible.then_some(standard.into()));
}
+65 -6
View File
@@ -84,6 +84,8 @@ const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock";
pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock";
const AMAZON_BEDROCK_RUNTIME_PROVIDER_NAME: &str = "Amazon Bedrock Runtime";
pub const AMAZON_BEDROCK_RUNTIME_PROVIDER_ID: &str = "amazon-bedrock-runtime";
const GCP_PROVIDER_NAME: &str = "Google Cloud Vertex AI";
pub const GCP_PROVIDER_ID: &str = "gcp";
pub const AMAZON_BEDROCK_GPT_5_5_MODEL_ID: &str = "openai.gpt-5.5";
pub const AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID: &str = "openai.gpt-5.6-sol";
pub const AMAZON_BEDROCK_GPT_6_1_SOL_MODEL_ID: &str = "openai.gpt-6.1-sol";
@@ -615,6 +617,34 @@ other non-default provider fields are not supported"
provider
}
pub fn create_gcp_provider() -> ModelProviderInfo {
ModelProviderInfo {
name: GCP_PROVIDER_NAME.into(),
base_url: None,
model_catalog_url: None,
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
// Config loading supplies default command auth after resolving its working directory.
auth: None,
gateway_oauth: None,
aws: None,
capabilities: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
include_internal_metadata: false,
}
}
pub fn is_openai(&self) -> bool {
self.name == OPENAI_PROVIDER_NAME
}
@@ -647,6 +677,10 @@ other non-default provider fields are not supported"
self.name == AMAZON_BEDROCK_RUNTIME_PROVIDER_NAME
}
pub fn is_gcp(&self) -> bool {
self.name == GCP_PROVIDER_NAME
}
pub fn has_command_auth(&self) -> bool {
self.auth.is_some()
}
@@ -667,14 +701,14 @@ pub fn built_in_model_providers(
let amazon_bedrock_provider = P::create_amazon_bedrock_provider(/*aws*/ None);
let amazon_bedrock_runtime_provider =
P::create_amazon_bedrock_runtime_provider(/*aws*/ None);
let gcp_provider = P::create_gcp_provider();
// We do not want to be in the business of adjucating which third-party
// providers are bundled with Codex CLI, so we only include the OpenAI and
// open source ("oss") providers by default. Users are encouraged to add to
// `model_providers` in config.toml to add their own providers.
// Bundle OpenAI, supported cloud-hosted OpenAI backends, and local OSS providers.
// Other compatible providers can be added through `model_providers` in config.toml.
[
(OPENAI_PROVIDER_ID, openai_provider),
(AMAZON_BEDROCK_PROVIDER_ID, amazon_bedrock_provider),
(GCP_PROVIDER_ID, gcp_provider),
(
AMAZON_BEDROCK_RUNTIME_PROVIDER_ID,
amazon_bedrock_runtime_provider,
@@ -696,8 +730,8 @@ pub fn built_in_model_providers(
/// Merge configured providers into the built-in provider catalog.
///
/// Configured providers extend the built-in set. Built-in providers are not
/// generally overridable, but built-in Amazon Bedrock providers allow the user
/// to customize their endpoint, authentication, headers, and AWS settings.
/// generally overridable, but the cloud-hosted OpenAI providers allow a limited
/// set of endpoint, authentication, header, and cloud-specific overrides.
pub fn merge_configured_model_providers(
mut model_providers: HashMap<String, ModelProviderInfo>,
configured_model_providers: HashMap<String, ModelProviderInfo>,
@@ -727,6 +761,31 @@ pub fn merge_configured_model_providers(
.extend(http_headers_override);
}
}
} else if key == GCP_PROVIDER_ID {
let base_url_override = provider.base_url.take();
let auth_override = provider.auth.take();
let http_headers_override = provider.http_headers.take();
if provider != ModelProviderInfo::default() {
return Err(format!(
"model_providers.{GCP_PROVIDER_ID} only supports changing \
`base_url`, `auth`, and `http_headers`; other non-default provider fields are not supported"
));
}
if let Some(built_in_provider) = model_providers.get_mut(GCP_PROVIDER_ID) {
if let Some(base_url_override) = base_url_override {
built_in_provider.base_url = Some(base_url_override);
}
if let Some(auth_override) = auth_override {
built_in_provider.auth = Some(auth_override);
}
if let Some(http_headers_override) = http_headers_override {
built_in_provider
.http_headers
.get_or_insert_default()
.extend(http_headers_override);
}
}
} else {
model_providers.entry(key).or_insert(provider);
}
@@ -637,6 +637,64 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri
);
}
#[test]
fn test_merge_configured_model_providers_applies_gcp_transport_overrides() {
let auth = provider_auth_for_test();
let configured_model_providers = std::collections::HashMap::from([(
GCP_PROVIDER_ID.to_string(),
ModelProviderInfo {
base_url: Some("https://vertex.example.com/v1".to_string()),
auth: Some(auth.clone()),
http_headers: Some(maplit::hashmap! {
"x-example-header".to_string() => "value".into(),
}),
..ModelProviderInfo::default()
},
)]);
let mut expected = built_in_model_providers(/*openai_base_url*/ None);
let provider = expected
.get_mut(GCP_PROVIDER_ID)
.expect("GCP provider should be built in");
provider.base_url = Some("https://vertex.example.com/v1".to_string());
provider.auth = Some(auth);
provider.http_headers = Some(maplit::hashmap! {
"x-example-header".to_string() => "value".into(),
});
assert_eq!(
merge_configured_model_providers(
built_in_model_providers(/*openai_base_url*/ None),
configured_model_providers,
),
Ok(expected)
);
}
#[test]
fn test_merge_configured_model_providers_applies_gcp_endpoint_override() {
let configured_model_providers = std::collections::HashMap::from([(
GCP_PROVIDER_ID.to_string(),
ModelProviderInfo {
base_url: Some("https://vertex.example.com/v1".to_string()),
..ModelProviderInfo::default()
},
)]);
let mut expected = built_in_model_providers(/*openai_base_url*/ None);
expected
.get_mut(GCP_PROVIDER_ID)
.expect("GCP provider should be built in")
.base_url = Some("https://vertex.example.com/v1".to_string());
assert_eq!(
merge_configured_model_providers(
built_in_model_providers(/*openai_base_url*/ None),
configured_model_providers,
),
Ok(expected)
);
}
#[test]
fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fields() {
let configured_model_providers = std::collections::HashMap::from([(
@@ -279,6 +279,7 @@ impl ModelProvider for AmazonBedrockModelProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
reasoning_summaries: true,
image_generation: false,
web_search: self.endpoint == BedrockEndpoint::Mantle,
external_web_access: false,
@@ -696,6 +697,7 @@ mod tests {
assert_eq!(
provider.capabilities(),
ProviderCapabilities {
reasoning_summaries: true,
image_generation: false,
web_search: true,
external_web_access: false,
@@ -714,6 +716,7 @@ mod tests {
assert_eq!(
provider.capabilities(),
ProviderCapabilities {
reasoning_summaries: true,
image_generation: false,
web_search: false,
external_web_access: false,
@@ -10,6 +10,8 @@ use codex_model_provider_info::RemoteCompactionSupport;
/// can further restrict the functionality exposed to the model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCapabilities {
/// Whether requests may include the Responses API `reasoning.summary` parameter.
pub reasoning_summaries: bool,
pub image_generation: bool,
pub web_search: bool,
pub external_web_access: bool,
@@ -19,6 +21,7 @@ pub struct ProviderCapabilities {
impl Default for ProviderCapabilities {
fn default() -> Self {
Self {
reasoning_summaries: true,
image_generation: true,
web_search: true,
external_web_access: true,
@@ -0,0 +1,176 @@
use codex_models_manager::ModelsManagerConfig;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::protocol::MultiAgentVersion;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn vertex_base_url_defaults_to_global_location() {
assert_eq!(
vertex_base_url(Some("example-project"), /*location*/ None)
.expect("project should resolve a Vertex endpoint"),
"https://aiplatform.googleapis.com/v1/projects/example-project/locations/global/publishers/openai/v1"
);
}
#[test]
fn vertex_base_url_uses_explicit_location() {
assert_eq!(
vertex_base_url(Some("example-project"), Some("us-central1"))
.expect("project and location should resolve a Vertex endpoint"),
"https://aiplatform.googleapis.com/v1/projects/example-project/locations/us-central1/publishers/openai/v1"
);
}
#[test]
fn vertex_base_url_explains_missing_project() {
let error = vertex_base_url(/*project*/ None, /*location*/ None)
.expect_err("missing project should be rejected");
assert_eq!(
error.to_string(),
"Missing environment variable: `GOOGLE_CLOUD_PROJECT`. Set GOOGLE_CLOUD_PROJECT to your Google Cloud project ID, or configure model_providers.gcp.base_url."
);
}
#[test]
fn vertex_base_url_rejects_path_components() {
let error = vertex_base_url(Some("example-project/other"), /*location*/ None)
.expect_err("project must not add endpoint path components");
assert_eq!(
error.to_string(),
"GOOGLE_CLOUD_PROJECT must contain only ASCII letters, digits, and hyphens"
);
}
#[tokio::test]
async fn configured_base_url_bypasses_project_discovery() {
let mut provider_info = ModelProviderInfo::create_gcp_provider();
provider_info.base_url = Some("https://vertex.example.com/v1".to_string());
provider_info.auth = Some(ModelProviderAuthInfo {
command: "print-token".to_string(),
args: Vec::new(),
timeout_ms: std::num::NonZeroU64::new(5_000).expect("timeout should be non-zero"),
refresh_interval_ms: 300_000,
cwd: std::env::current_dir()
.expect("current directory should be available")
.try_into()
.expect("current directory should be absolute"),
});
let provider = GcpModelProvider::new(provider_info, /*auth_manager*/ None);
assert_eq!(
provider
.api_provider()
.await
.expect("configured endpoint should build")
.base_url,
"https://vertex.example.com/v1"
);
assert!(
provider
.auth_manager()
.expect("GCP should create a command-backed auth manager")
.has_external_auth()
);
}
#[tokio::test]
async fn bundled_vertex_models_filter_catalog_and_normalize_unsupported_tool_metadata() {
let provider = GcpModelProvider::new(
ModelProviderInfo::create_gcp_provider(),
/*auth_manager*/ None,
);
let manager = provider.models_manager(
PathBuf::from("unused-codex-home"),
/*config_model_catalog*/ None,
);
let bundled_models = codex_models_manager::bundled_models_response()
.expect("bundled model catalog should deserialize")
.models;
let mut expected: Vec<_> = ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-luna"]
.into_iter()
.map(|slug| {
bundled_models
.iter()
.find(|model| model.slug == slug)
.expect("bundled catalog should contain the supported Vertex model")
.clone()
})
.collect();
for model in &mut expected {
model.use_responses_lite = false;
model.tool_mode = Some(ToolMode::Direct);
model.apply_patch_tool_type = None;
}
assert_eq!(manager.get_remote_models().await, expected);
assert_eq!(
manager
.try_list_models()
.expect("static models should be available")
.into_iter()
.map(|model| (model.model, model.show_in_picker))
.collect::<Vec<_>>(),
vec![
("gpt-6.1-sol".to_string(), true),
("gpt-6-astra".to_string(), true),
("gpt-6-luna".to_string(), true),
]
);
}
#[tokio::test]
async fn configured_vertex_models_filter_catalog_and_normalize_tool_metadata_without_cache() {
let provider = GcpModelProvider::new(
ModelProviderInfo::create_gcp_provider(),
/*auth_manager*/ None,
);
let mut configured_model = codex_models_manager::bundled_models_response()
.expect("bundled model catalog should deserialize")
.models
.into_iter()
.find(|model| model.slug == "gpt-6.1-sol")
.expect("bundled model catalog should contain gpt-6.1-sol");
configured_model.use_responses_lite = true;
configured_model.tool_mode = Some(ToolMode::CodeModeOnly);
configured_model.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform);
configured_model.multi_agent_version = Some(MultiAgentVersion::V2);
let mut expected_model = configured_model.clone();
expected_model.use_responses_lite = false;
expected_model.tool_mode = Some(ToolMode::Direct);
expected_model.apply_patch_tool_type = None;
let mut unsupported_model = configured_model.clone();
unsupported_model.slug = "gpt-6-sol".to_string();
let mut unsupported_variant = configured_model.clone();
unsupported_variant.slug = "gpt-6.1-sol-extra".to_string();
let manager = provider.models_manager_without_cache(Some(ModelsResponse {
models: vec![unsupported_model, configured_model, unsupported_variant],
}));
assert_eq!(manager.get_remote_models().await, vec![expected_model]);
}
#[tokio::test]
async fn unknown_vertex_models_use_static_catalog_fallback() {
let provider = GcpModelProvider::new(
ModelProviderInfo::create_gcp_provider(),
/*auth_manager*/ None,
);
let manager = provider.models_manager(
PathBuf::from("unused-codex-home"),
Some(ModelsResponse::default()),
);
let model = manager
.get_model_info("vertex-test-model", &ModelsManagerConfig::default())
.await;
assert_eq!(
(model.slug, model.used_fallback_model_metadata),
("vertex-test-model".to_string(), true)
);
}
+215
View File
@@ -0,0 +1,215 @@
//! Vertex AI provider routing, command-backed credentials, and supported model capabilities.
use std::path::PathBuf;
use std::sync::Arc;
use codex_api::Provider;
use codex_api::SharedAuthProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::manager::StaticModelsManager;
use codex_protocol::account::ProviderAccount;
use codex_protocol::error::CodexErr;
use codex_protocol::error::EnvVarError;
use codex_protocol::error::Result;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ToolMode;
use crate::ProviderCapabilities;
use crate::RemoteCompactionSupport;
use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth;
use crate::provider::ModelProvider;
use crate::provider::ModelProviderFuture;
use crate::provider::ProviderAccountResult;
use crate::provider::ProviderAccountState;
const GOOGLE_CLOUD_PROJECT_ENV_VAR: &str = "GOOGLE_CLOUD_PROJECT";
const GOOGLE_CLOUD_LOCATION_ENV_VAR: &str = "GOOGLE_CLOUD_LOCATION";
const DEFAULT_GOOGLE_CLOUD_LOCATION: &str = "global";
/// Runtime provider for Google Cloud Vertex AI's OpenAI-compatible endpoint.
#[derive(Clone, Debug)]
pub(crate) struct GcpModelProvider {
info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
}
impl GcpModelProvider {
pub(crate) fn new(
provider_info: ModelProviderInfo,
auth_manager: Option<Arc<AuthManager>>,
) -> Self {
let auth_manager = auth_manager_for_provider(auth_manager, &provider_info);
Self {
info: provider_info,
auth_manager,
}
}
async fn auth(&self) -> Option<CodexAuth> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
}
}
async fn api_provider(&self) -> Result<Provider> {
let mut provider_info = self.info.clone();
provider_info.base_url = self.runtime_base_url().await?;
provider_info.to_api_provider(/*auth_mode*/ None)
}
async fn runtime_base_url(&self) -> Result<Option<String>> {
if let Some(base_url) = self.info.base_url.clone() {
return Ok(Some(base_url));
}
let project = std::env::var(GOOGLE_CLOUD_PROJECT_ENV_VAR).ok();
let location = std::env::var(GOOGLE_CLOUD_LOCATION_ENV_VAR).ok();
vertex_base_url(project.as_deref(), location.as_deref()).map(Some)
}
}
impl ModelProvider for GcpModelProvider {
fn info(&self) -> &ModelProviderInfo {
&self.info
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
reasoning_summaries: false,
image_generation: false,
web_search: false,
external_web_access: false,
remote_compaction: RemoteCompactionSupport::V2,
}
}
fn approval_review_preferred_model(&self) -> &'static str {
// TODO: Switch Guardian to gpt-5.6-luna once it is available on GCP.
"gpt-6-luna"
}
fn memory_extraction_preferred_model(&self) -> &'static str {
"gpt-6-luna"
}
fn memory_consolidation_preferred_model(&self) -> &'static str {
"gpt-6.1-sol"
}
fn auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
Box::pin(GcpModelProvider::auth(self))
}
fn account_state(&self) -> ProviderAccountResult {
Ok(ProviderAccountState {
account: Some(ProviderAccount::Gcp),
requires_openai_auth: false,
})
}
fn api_provider(&self) -> ModelProviderFuture<'_, Result<Provider>> {
Box::pin(GcpModelProvider::api_provider(self))
}
fn api_auth(&self) -> ModelProviderFuture<'_, Result<SharedAuthProvider>> {
Box::pin(async move {
let auth = self.auth().await.ok_or_else(|| {
CodexErr::InvalidRequest(
"Google Cloud credentials are unavailable or expired. Run \
`gcloud auth application-default login` to reauthenticate."
.to_string(),
)
})?;
resolve_provider_auth(Some(&auth), &self.info)
})
}
fn runtime_base_url(&self) -> ModelProviderFuture<'_, Result<Option<String>>> {
Box::pin(GcpModelProvider::runtime_base_url(self))
}
fn models_manager(
&self,
_codex_home: PathBuf,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
self.models_manager_without_cache(config_model_catalog)
}
fn models_manager_without_cache(
&self,
config_model_catalog: Option<ModelsResponse>,
) -> SharedModelsManager {
let mut model_catalog = config_model_catalog
.or_else(|| codex_models_manager::bundled_models_response().ok())
.unwrap_or_default();
model_catalog.models.retain(|model| {
matches!(
model.slug.as_str(),
"gpt-6-astra" | "gpt-6-luna" | "gpt-6.1-sol"
)
});
// Vertex supports function tools, namespaces, and client-side tool search, but not
// Responses Lite or custom tools.
for model in &mut model_catalog.models {
model.use_responses_lite = false;
model.tool_mode = Some(ToolMode::Direct);
model.apply_patch_tool_type = None;
}
Arc::new(StaticModelsManager::new(
self.auth_manager.clone(),
model_catalog,
))
}
}
fn vertex_base_url(project: Option<&str>, location: Option<&str>) -> Result<String> {
let project = project
.map(str::trim)
.filter(|project| !project.is_empty())
.ok_or_else(|| {
CodexErr::EnvVar(EnvVarError {
var: GOOGLE_CLOUD_PROJECT_ENV_VAR.to_string(),
instructions: Some(
"Set GOOGLE_CLOUD_PROJECT to your Google Cloud project ID, or configure \
model_providers.gcp.base_url."
.to_string(),
),
})
})?;
let location = location
.map(str::trim)
.filter(|location| !location.is_empty())
.unwrap_or(DEFAULT_GOOGLE_CLOUD_LOCATION);
for (name, value) in [
(GOOGLE_CLOUD_PROJECT_ENV_VAR, project),
(GOOGLE_CLOUD_LOCATION_ENV_VAR, location),
] {
if !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
{
return Err(CodexErr::InvalidRequest(format!(
"{name} must contain only ASCII letters, digits, and hyphens"
)));
}
}
Ok(format!(
"https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/openai/v1"
))
}
#[cfg(test)]
#[path = "gcp_tests.rs"]
mod tests;
+2
View File
@@ -3,6 +3,7 @@ mod auth;
mod bearer_auth_provider;
mod capabilities;
mod combined_auth;
mod gcp;
mod models_endpoint;
mod models_identity;
mod provider;
@@ -29,6 +30,7 @@ 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::GCP_PROVIDER_ID;
pub use codex_model_provider_info::RemoteCompactionSupport;
pub use codex_protocol::account::ProviderAccount;
pub use provider::ModelProvider;
+4
View File
@@ -33,6 +33,7 @@ use crate::auth::auth_manager_for_provider;
use crate::auth::resolve_provider_auth;
use crate::auth::resolve_provider_auth_for_scope;
use crate::combined_auth::compose_auth;
use crate::gcp::GcpModelProvider;
use crate::models_endpoint::OpenAiModelsEndpoint;
use crate::workspace_routing::WorkspaceRoutingContext;
@@ -348,6 +349,9 @@ pub fn create_model_provider(
if provider_info.is_amazon_bedrock() {
return Arc::new(AmazonBedrockModelProvider::new(provider_info, auth_manager));
}
if provider_info.is_gcp() {
return Arc::new(GcpModelProvider::new(provider_info, auth_manager));
}
let gateway_auth_manager = provider_info.gateway_oauth.as_ref().map(|config| {
provider_info.validate()?;
let manager = auth_manager
+1
View File
@@ -59,6 +59,7 @@ pub enum ProviderAccount {
AmazonBedrock {
uses_codex_managed_credentials: bool,
},
Gcp,
}
impl PlanType {
+1 -1
View File
@@ -821,7 +821,7 @@ impl AppServerSession {
true,
)
}
Some(Account::AmazonBedrock { .. }) => {
Some(Account::AmazonBedrock { .. } | Account::Gcp {}) => {
(None, None, None, None, FeedbackAudience::External, false)
}
None => (None, None, None, None, FeedbackAudience::External, false),
+1 -1
View File
@@ -1320,7 +1320,7 @@ async fn background_turn_program(
}
true
}
Some(Account::AmazonBedrock { .. }) | None => false,
Some(Account::AmazonBedrock { .. } | Account::Gcp {}) | None => false,
};
// Retain saved metadata while suppressing it for accounts that cannot use Daybreak.
let enabled = enabled && eligible;
+3 -1
View File
@@ -2292,7 +2292,9 @@ async fn get_login_status(
let login_status = match &account.account {
Some(AppServerAccount::ApiKey {}) => LoginStatus::AuthMode(AuthMode::ApiKey),
Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AuthMode::Chatgpt),
Some(AppServerAccount::AmazonBedrock { .. }) | None => LoginStatus::NotAuthenticated,
Some(AppServerAccount::AmazonBedrock { .. } | AppServerAccount::Gcp {}) | None => {
LoginStatus::NotAuthenticated
}
};
Ok((login_status, account))
}
@@ -43,6 +43,13 @@ class AmazonBedrockAccount(BaseModel):
] = False
class GcpAccount(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
type: Annotated[Literal["gcp"], Field(title="GcpAccountType")]
class AccountRoutingOverride(Enum):
no_constraint = "NO_CONSTRAINT"
us = "us"
@@ -6847,11 +6854,11 @@ class ChatgptAccount(BaseModel):
type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")]
class Account(RootModel[ApiKeyAccount | ChatgptAccount | AmazonBedrockAccount]):
class Account(RootModel[ApiKeyAccount | ChatgptAccount | AmazonBedrockAccount | GcpAccount]):
model_config = ConfigDict(
populate_by_name=True,
)
root: ApiKeyAccount | ChatgptAccount | AmazonBedrockAccount
root: ApiKeyAccount | ChatgptAccount | AmazonBedrockAccount | GcpAccount
class AccountLoginCompletedNotification(BaseModel):