Allow configuring GCP project and location in provider settings (#56560)

## Why

Deriving a Vertex AI endpoint previously required environment variables. Allow the project and location to be specified directly in GCP provider configuration.

## What changed

- Add `model_providers.gcp.project_id` and `model_providers.gcp.location`, with each taking precedence over its corresponding environment variable. Keep `global` as the default location and preserve explicit `base_url` overrides.
- Validate configured endpoint fields and preserve them through thread configuration serialization.
- Apply built-in GCP defaults, including default command authentication, when checking managed provider requirements so unchanged requirements are accepted.

## Testing

Add coverage for endpoint precedence, invalid explicit configuration, provider construction, thread configuration round trips, and managed GCP requirement checks, including repeated model listing.

GitOrigin-RevId: c3704bcc0b7d4872374e2cae623fd24d75fb02b4
This commit is contained in:
alexsong-oai
2026-10-23 18:50:54 +00:00
committed by copyberry
parent 7848e3393c
commit 970111f6cb
23 changed files with 315 additions and 51 deletions
+14 -4
View File
@@ -4,6 +4,7 @@ use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLayerStack;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_config::apply_default_gcp_auth;
use codex_config::loader::load_config_layers_state;
use codex_config::loader::load_managed_requirements_state;
use codex_core::config::Config;
@@ -15,6 +16,7 @@ use codex_login::AuthManager;
use codex_login::default_client::set_default_client_residency_requirement;
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::built_in_model_providers;
use codex_model_provider_info::merge_configured_model_providers;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -371,16 +373,24 @@ impl ConfigManager {
Some(provider)
if matches!(
current.model_provider_id.as_str(),
AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
AMAZON_BEDROCK_PROVIDER_ID
| AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
| GCP_PROVIDER_ID
) =>
{
// Bedrock requirements contain overrides of the built-in provider.
merge_configured_model_providers(
// Managed overrides must receive the same built-in defaults as startup config.
let mut provider = merge_configured_model_providers(
built_in_model_providers(/*openai_base_url*/ None),
HashMap::from([(current.model_provider_id.clone(), provider)]),
)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?
.remove(&current.model_provider_id)
.remove(&current.model_provider_id);
if current.model_provider_id == GCP_PROVIDER_ID
&& let Some(provider) = provider.as_mut()
{
apply_default_gcp_auth(provider, &current.cwd);
}
provider
}
Some(provider) => Some(provider),
None => None,
@@ -14,13 +14,22 @@ use pretty_assertions::assert_eq;
use tempfile::tempdir;
use test_case::test_case;
#[test_case(AMAZON_BEDROCK_PROVIDER_ID; "bedrock")]
#[test_case(AMAZON_BEDROCK_RUNTIME_PROVIDER_ID; "bedrock_runtime")]
#[test_case(AMAZON_BEDROCK_PROVIDER_ID, "aws.region = 'us-west-2'", "us-west-2", "us-east-1"; "bedrock")]
#[test_case(AMAZON_BEDROCK_RUNTIME_PROVIDER_ID, "aws.region = 'us-west-2'", "us-west-2", "us-east-1"; "bedrock_runtime")]
#[test_case(GCP_PROVIDER_ID, "project_id = 'example-project'\nlocation = 'us-central1'", "example-project", "other-project"; "gcp_project")]
#[test_case(GCP_PROVIDER_ID, "project_id = 'example-project'\nlocation = 'us-central1'", "us-central1", "us-east4"; "gcp_location")]
#[test_case(GCP_PROVIDER_ID, "project_id = 'example-project'\nauth.command = 'print-token'", "print-token", "other-token"; "gcp_auth")]
#[tokio::test]
async fn provider_requirements_resolve_bedrock_overrides(provider_id: &str) -> Result<()> {
async fn provider_requirements_resolve_builtin_overrides(
provider_id: &str,
provider_config: &str,
original: &str,
replacement: &str,
) -> Result<()> {
let home = tempdir()?;
let workspace = tempdir()?;
let requirements = format!(
"model_provider = '{provider_id}'\n[model_providers.{provider_id}.aws]\nregion = 'us-west-2'"
"model_provider = '{provider_id}'\n[model_providers.{provider_id}]\n{provider_config}"
);
let manager = ConfigManager::new_for_tests(
home.path().to_path_buf(),
@@ -28,7 +37,9 @@ async fn provider_requirements_resolve_bedrock_overrides(provider_id: &str) -> R
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(&requirements),
);
let current = manager.load_latest_config(/*fallback_cwd*/ None).await?;
let current = manager
.load_latest_config(Some(workspace.path().to_path_buf()))
.await?;
manager.check_thread_model_provider(&current).await?;
let changed = ConfigManager::new_for_tests(
@@ -36,7 +47,7 @@ async fn provider_requirements_resolve_bedrock_overrides(provider_id: &str) -> R
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
requirements.replace("us-west-2", "us-east-1"),
requirements.replace(original, replacement),
),
);
assert_eq!(
@@ -655,6 +655,8 @@ mod thread_processor_behavior_tests {
supports_websockets: true,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
let config_manager = ConfigManager::new(
@@ -48,6 +48,32 @@ async fn list_models(server: &mut TestAppServer) -> Result<ModelListResponse> {
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_list_accepts_unchanged_gcp_requirements() -> Result<()> {
let home = TempDir::new()?;
std::fs::write(home.path().join("config.toml"), "")?;
std::fs::write(
home.path().join("requirements.toml"),
r#"
model_provider = "gcp"
[model_providers.gcp]
project_id = "managed-project"
location = "us-central1"
"#,
)?;
let mut server = TestAppServer::builder()
.with_mock_chatgpt_backend()
.with_codex_home(home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
let initial = list_models(&mut server).await?;
assert!(!initial.data.is_empty());
assert_eq!(list_models(&mut server).await?, initial);
Ok(())
}
async fn sign_in(server: &mut TestAppServer) -> Result<()> {
let id = server
.send_login_account_api_key_request("sk-catalog-test")
+2
View File
@@ -30,6 +30,7 @@ pub use mcp_ema_cancellation::McpEmaCancellation;
mod mcp_requirements;
mod mcp_types;
mod merge;
mod model_provider_defaults;
mod model_provider_requirements;
mod overrides;
mod path_context;
@@ -201,6 +202,7 @@ pub use merge::ShellEnvironmentPolicyFilterRepresentation;
pub use merge::is_structured_feature_path;
pub use merge::merge_toml_values;
pub use merge::shell_environment_filter_entry;
pub use model_provider_defaults::apply_default_gcp_auth;
pub use overrides::build_cli_overrides_layer;
pub use path_context::ConfigPathContext;
pub use plugin_edit::PluginConfigEdit;
@@ -0,0 +1,27 @@
//! Applies runtime provider defaults using the resolved session working directory.
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::config_types::ModelProviderAuthInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::num::NonZeroU64;
/// Supplies GCP command auth when no explicit auth override is configured.
pub fn apply_default_gcp_auth(provider: &mut ModelProviderInfo, cwd: &AbsolutePathBuf) {
provider.auth.get_or_insert_with(|| ModelProviderAuthInfo {
command: if cfg!(windows) {
"gcloud.cmd"
} else {
"gcloud"
}
.to_string(),
args: vec![
"auth".into(),
"application-default".into(),
"print-access-token".into(),
],
timeout_ms: NonZeroU64::new(30_000).unwrap_or(NonZeroU64::MIN),
refresh_interval_ms: 3_000_000,
// Registry construction must not depend on the process cwd, which may be deleted.
cwd: cwd.clone(),
});
}
+2
View File
@@ -317,6 +317,8 @@ mod tests {
supports_websockets: true,
supports_standalone_web_search: true,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
@@ -50,6 +50,8 @@ message ModelProvider {
bool supports_websockets = 17;
bool supports_standalone_web_search = 18;
optional string model_catalog_url = 19;
optional string project_id = 20;
optional string location = 21;
}
message StringMap {
@@ -79,6 +79,10 @@ pub struct ModelProvider {
pub supports_standalone_web_search: bool,
#[prost(string, optional, tag = "19")]
pub model_catalog_url: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag = "20")]
pub project_id: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag = "21")]
pub location: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct StringMap {
@@ -196,6 +196,8 @@ fn model_provider_from_proto(
supports_websockets: provider.supports_websockets,
supports_standalone_web_search: provider.supports_standalone_web_search,
capabilities: None,
project_id: provider.project_id,
location: provider.location,
include_internal_metadata: false,
};
Ok((id, info))
@@ -216,6 +218,8 @@ fn model_provider_to_proto(
auth,
gateway_oauth: _,
aws: _,
project_id,
location,
wire_api,
query_params,
http_headers,
@@ -251,6 +255,8 @@ fn model_provider_to_proto(
requires_openai_auth,
supports_websockets,
supports_standalone_web_search,
project_id,
location,
}
}
@@ -446,6 +452,8 @@ mod tests {
#[test]
fn model_provider_proto_roundtrips_through_domain_type() {
let mut expected = expected_provider();
expected.project_id = Some("vertex-project".to_string());
expected.location = Some("us-central1".to_string());
expected.auth = None;
expected.experimental_bearer_token = Some("synthetic-provider-token".into());
let proto = model_provider_to_proto("local", expected.clone());
@@ -520,6 +528,8 @@ mod tests {
requires_openai_auth: false,
supports_websockets: true,
supports_standalone_web_search: true,
project_id: None,
location: None,
}],
features: HashMap::from([
("plugins".to_string(), false),
@@ -585,6 +595,8 @@ mod tests {
gateway_oauth: None,
aws: None,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
+8
View File
@@ -3024,6 +3024,10 @@
"description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.",
"type": "object"
},
"location": {
"description": "Google Cloud location for the built-in GCP provider. Takes precedence over `GOOGLE_CLOUD_LOCATION`; defaults to `global` when neither is set.",
"type": "string"
},
"model_catalog_url": {
"description": "Optional full URL for a Codex-native model catalog. When unset, OpenAI discovery uses the Codex backend unless `base_url` overrides the inference endpoint.",
"type": "string"
@@ -3033,6 +3037,10 @@
"description": "Friendly display name.",
"type": "string"
},
"project_id": {
"description": "Google Cloud project ID for the built-in GCP provider. Takes precedence over `GOOGLE_CLOUD_PROJECT` when deriving the Vertex AI endpoint.",
"type": "string"
},
"query_params": {
"additionalProperties": {
"type": "string"
+3 -16
View File
@@ -25,6 +25,7 @@ use codex_config::ResidencyRequirement;
use codex_config::SandboxModeRequirement;
use codex_config::Sourced;
use codex_config::ThreadConfigLoader;
use codex_config::apply_default_gcp_auth;
use codex_config::config_toml::CircuitBreakAction;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
@@ -101,7 +102,6 @@ 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;
@@ -3995,21 +3995,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))?;
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(),
});
if let Some(provider) = model_providers.get_mut(GCP_PROVIDER_ID) {
apply_default_gcp_auth(provider, &resolved_cwd);
}
let model_provider_id = config_layer_stack.required_model_provider().map(str::to_string)
+6
View File
@@ -93,6 +93,8 @@ async fn responses_stream_includes_subagent_header_on_review() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -240,6 +242,8 @@ async fn responses_stream_includes_subagent_header_on_other() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -368,6 +372,8 @@ async fn responses_respects_model_info_overrides_from_config() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
+8
View File
@@ -1734,6 +1734,8 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -3240,6 +3242,8 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids(
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -4088,6 +4092,8 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -4176,6 +4182,8 @@ async fn env_var_overrides_loaded_auth() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -2733,6 +2733,8 @@ fn websocket_provider_with_connect_timeout(
supports_websockets: true,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
@@ -85,6 +85,8 @@ async fn continue_after_stream_error() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -71,6 +71,8 @@ async fn retries_on_early_close() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
+2
View File
@@ -80,6 +80,8 @@ mod tests {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
+19 -1
View File
@@ -165,6 +165,12 @@ pub struct ModelProviderInfo {
pub gateway_oauth: Option<GatewayOAuthConfig>,
/// AWS SigV4 auth configuration for this provider.
pub aws: Option<ModelProviderAwsAuthInfo>,
/// Google Cloud project ID for the built-in GCP provider. Takes precedence
/// over `GOOGLE_CLOUD_PROJECT` when deriving the Vertex AI endpoint.
pub project_id: Option<String>,
/// Google Cloud location for the built-in GCP provider. Takes precedence
/// over `GOOGLE_CLOUD_LOCATION`; defaults to `global` when neither is set.
pub location: Option<String>,
/// Which wire protocol this provider expects.
#[serde(default)]
pub wire_api: WireApi,
@@ -565,6 +571,8 @@ other non-default provider fields are not supported"
supports_websockets: true,
supports_standalone_web_search: true,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: true,
}
}
@@ -605,6 +613,8 @@ other non-default provider fields are not supported"
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
@@ -641,6 +651,8 @@ other non-default provider fields are not supported"
requires_openai_auth: false,
supports_websockets: false,
supports_standalone_web_search: false,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
@@ -765,14 +777,18 @@ pub fn merge_configured_model_providers(
let base_url_override = provider.base_url.take();
let auth_override = provider.auth.take();
let http_headers_override = provider.http_headers.take();
let project_id_override = provider.project_id.take();
let location_override = provider.location.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"
`base_url`, `auth`, `http_headers`, `project_id`, and `location`; other non-default provider fields are not supported"
));
}
if let Some(built_in_provider) = model_providers.get_mut(GCP_PROVIDER_ID) {
built_in_provider.project_id = project_id_override;
built_in_provider.location = location_override;
if let Some(base_url_override) = base_url_override {
built_in_provider.base_url = Some(base_url_override);
}
@@ -836,6 +852,8 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}
@@ -97,6 +97,8 @@ base_url = "http://localhost:11434/v1"
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -136,6 +138,8 @@ query_params = { api-version = "2025-04-01-preview" }
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -179,6 +183,8 @@ supports_standalone_web_search = true
supports_websockets: false,
supports_standalone_web_search: true,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
};
@@ -369,6 +375,8 @@ fn test_create_amazon_bedrock_provider() {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
);
@@ -644,6 +652,8 @@ fn test_merge_configured_model_providers_applies_gcp_transport_overrides() {
GCP_PROVIDER_ID.to_string(),
ModelProviderInfo {
base_url: Some("https://vertex.example.com/v1".to_string()),
project_id: Some("configured-project".to_string()),
location: Some("us-east4".to_string()),
auth: Some(auth.clone()),
http_headers: Some(maplit::hashmap! {
"x-example-header".to_string() => "value".into(),
@@ -657,6 +667,8 @@ fn test_merge_configured_model_providers_applies_gcp_transport_overrides() {
.get_mut(GCP_PROVIDER_ID)
.expect("GCP provider should be built in");
provider.base_url = Some("https://vertex.example.com/v1".to_string());
provider.project_id = Some("configured-project".to_string());
provider.location = Some("us-east4".to_string());
provider.auth = Some(auth);
provider.http_headers = Some(maplit::hashmap! {
"x-example-header".to_string() => "value".into(),
+96 -10
View File
@@ -9,8 +9,12 @@ 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"),
vertex_base_url(
&ModelProviderInfo::default(),
Some("example-project"),
/*environment_location*/ None
)
.expect("project should resolve a Vertex endpoint"),
"https://aiplatform.googleapis.com/v1/projects/example-project/locations/global/publishers/openai/v1"
);
}
@@ -18,31 +22,111 @@ fn vertex_base_url_defaults_to_global_location() {
#[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"),
vertex_base_url(
&ModelProviderInfo::default(),
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");
let error = vertex_base_url(
&ModelProviderInfo::default(),
/*environment_project*/ None,
/*environment_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."
"Missing environment variable: `GOOGLE_CLOUD_PROJECT`. Set model_providers.gcp.project_id or 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");
let error = vertex_base_url(
&ModelProviderInfo::default(),
Some("example-project/other"),
/*environment_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"
"GOOGLE_CLOUD_PROJECT must be non-empty and contain only ASCII letters, digits, and hyphens"
);
}
#[test]
fn configured_vertex_endpoint_resolves_each_field_independently() {
for (project_id, location, expected) in [
(
Some("configured-project"),
Some("us-east4"),
"https://aiplatform.googleapis.com/v1/projects/configured-project/locations/us-east4/publishers/openai/v1",
),
(
Some("configured-project"),
None,
"https://aiplatform.googleapis.com/v1/projects/configured-project/locations/us-central1/publishers/openai/v1",
),
(
None,
Some("us-east4"),
"https://aiplatform.googleapis.com/v1/projects/environment-project/locations/us-east4/publishers/openai/v1",
),
] {
let info = ModelProviderInfo {
project_id: project_id.map(str::to_string),
location: location.map(str::to_string),
..ModelProviderInfo::default()
};
assert_eq!(
vertex_base_url(&info, Some("environment-project"), Some("us-central1"))
.expect("configured fields should take precedence over environment values"),
expected
);
}
}
#[test]
fn empty_configured_project_does_not_fall_back_to_environment() {
let info = ModelProviderInfo {
project_id: Some(String::new()),
..ModelProviderInfo::default()
};
let error = vertex_base_url(&info, Some("environment-project"), Some("us-central1"))
.expect_err("invalid explicit config should not fall back to environment values");
assert_eq!(
error.to_string(),
"model_providers.gcp.project_id must be non-empty and contain only ASCII letters, digits, and hyphens"
);
}
#[tokio::test]
async fn configured_project_and_location_build_api_provider() {
let provider = GcpModelProvider::new(
ModelProviderInfo {
project_id: Some("configured-project".to_string()),
location: Some("us-east4".to_string()),
..ModelProviderInfo::create_gcp_provider()
},
/*auth_manager*/ None,
);
assert_eq!(
provider
.api_provider()
.await
.expect("configured endpoint should build")
.base_url,
"https://aiplatform.googleapis.com/v1/projects/configured-project/locations/us-east4/publishers/openai/v1"
);
}
@@ -60,6 +144,8 @@ async fn configured_base_url_bypasses_project_discovery() {
.try_into()
.expect("current directory should be absolute"),
});
provider_info.project_id = Some("configured-project".to_string());
provider_info.location = Some("us-east4".to_string());
let provider = GcpModelProvider::new(provider_info, /*auth_manager*/ None);
assert_eq!(
+45 -14
View File
@@ -69,7 +69,7 @@ impl GcpModelProvider {
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)
vertex_base_url(&self.info, project.as_deref(), location.as_deref()).map(Some)
}
}
@@ -172,35 +172,66 @@ impl ModelProvider for GcpModelProvider {
}
}
fn vertex_base_url(project: Option<&str>, location: Option<&str>) -> Result<String> {
let project = project
fn vertex_base_url(
info: &ModelProviderInfo,
environment_project: Option<&str>,
environment_location: Option<&str>,
) -> Result<String> {
let project = info
.project_id
.as_deref()
.map(str::trim)
.filter(|project| !project.is_empty())
.or_else(|| {
environment_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."
"Set model_providers.gcp.project_id or GOOGLE_CLOUD_PROJECT to your Google \
Cloud project ID, or configure model_providers.gcp.base_url."
.to_string(),
),
})
})?;
let location = location
let location = info
.location
.as_deref()
.map(str::trim)
.filter(|location| !location.is_empty())
.or_else(|| {
environment_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 info.project_id.is_some() {
"model_providers.gcp.project_id"
} else {
GOOGLE_CLOUD_PROJECT_ENV_VAR
},
project,
),
(
if info.location.is_some() {
"model_providers.gcp.location"
} else {
GOOGLE_CLOUD_LOCATION_ENV_VAR
},
location,
),
] {
if !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
if value.is_empty()
|| !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
{
return Err(CodexErr::InvalidRequest(format!(
"{name} must contain only ASCII letters, digits, and hyphens"
"{name} must be non-empty and contain only ASCII letters, digits, and hyphens"
)));
}
}
+2
View File
@@ -667,6 +667,8 @@ mod tests {
supports_websockets: false,
supports_standalone_web_search: false,
capabilities: None,
project_id: None,
location: None,
include_internal_metadata: false,
}
}