mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
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:
+17
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+17
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,7 @@ pub enum ProviderAccount {
|
||||
AmazonBedrock {
|
||||
uses_codex_managed_credentials: bool,
|
||||
},
|
||||
Gcp,
|
||||
}
|
||||
|
||||
impl PlanType {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user