Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,18 @@ If you don’t have the tool:
- Do not mark a feature/bugfix task complete until at least one automated end-to-end test against the real `codex` binary passes.
- Unit tests alone are not sufficient when user-visible behavior is changed.
- The E2E test must exercise the actual user workflow through CLI/TUI input handling (for example PTY-driven command entry), not only direct internal API calls.
- For model-switching changes, the E2E path must include `/model` selection and then a real prompt submission in the same session, with assertions on the outbound model used by `/responses`.
- For model-switching changes, the E2E path must include `/model` selection and then a real prompt submission in the same session, with assertions that the selected model slug is preserved in outbound provider calls (for example `/responses`, and `/chat/completions` when a provider-specific fallback is expected).
- For model-switching validation, launching `codex exec -m ...` is not sufficient. The required path is: start interactive `codex`, type `/model` in-session via PTY/stdin, select the model from the picker, then submit a real prompt in the same session.
- Tests and manual verification logs must show that `/model` was actually issued in-session before the prompt turn.
- For provider-backed model catalogs (for example GitHub Copilot), add coverage that `/model` surfaces all picker-enabled models returned by the provider `/models` endpoint, including entries that may not support `/responses`.

### Model/provider switching guardrails

- Treat provider identity as three separate things that may differ: the config key (`model_provider`), the human-readable provider name (`name`), and the upstream catalog/provider ID (`models.dev` or provider `/models`). Do not assume exact string equality between them.
- When changing `ModelsManager`, provider aliasing, `models.dev` matching, or provider `/models` handling, add or update at least one regression test with a non-canonical real-world provider name and a non-canonical base URL. Minimum required case: `name = "Azure OpenAI"` must still resolve to the `azure` catalog entry even when the base URL is a proxy or localhost host rather than an Azure hostname.
- When changing config rebuild, cwd switching, profile switching, or `/model` provider switching, add or update a regression test that proves `active_profile`, `model_provider_id`, and `model` survive the rebuild unless the change is intentionally resetting them.
- For provider-backed catalogs, discovery coverage alone is not enough. Tests must cover both picker population and post-selection execution, proving that the next outbound request uses the selected model on the correct wire API.
- When diagnosing provider/model failures, inspect effective config and persisted auth state before blaming missing environment variables. Do not stop at shell env inspection if `auth.json` or profile config can still supply credentials or provider state.

### Spawning workspace binaries in tests (Cargo vs Bazel)

Expand Down
125 changes: 113 additions & 12 deletions codex-rs/core/src/models_manager/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::models_manager::model_info;
use codex_api::AuthProvider;
use codex_api::ModelsClient;
use codex_api::ReqwestTransport;
use codex_api::is_azure_responses_wire_base_url;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
Expand Down Expand Up @@ -52,12 +53,22 @@ struct OpenAiCompatModel {
id: String,
#[serde(default)]
model_picker_enabled: Option<bool>,
#[serde(default)]
supported_endpoints: Vec<String>,
}

impl OpenAiCompatModel {
fn is_picker_enabled(&self) -> bool {
!matches!(self.model_picker_enabled, Some(false))
}

fn supports_responses_endpoint(&self) -> bool {
self.supported_endpoints.is_empty()
|| self
.supported_endpoints
.iter()
.any(|endpoint| endpoint.trim_end_matches('/').ends_with("/responses"))
}
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -572,12 +583,13 @@ impl ModelsManager {
.json()
.await
.map_err(|err| CodexErr::Stream(err.to_string(), None))?;
let model_ids = payload
let mut models = payload
.data
.into_iter()
.filter(|model| model.is_picker_enabled())
.map(|model| model.id)
.filter(OpenAiCompatModel::is_picker_enabled)
.collect::<Vec<_>>();
models.sort_by_key(|model| !model.supports_responses_endpoint());
let model_ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
let models = self.map_provider_model_ids(model_ids);
Ok((models, etag))
}
Expand Down Expand Up @@ -637,7 +649,7 @@ impl ModelsManager {
let model_ids = payload
.models
.into_iter()
.filter(|model| model.is_picker_enabled())
.filter(OllamaTagsModel::is_picker_enabled)
.map(|model| model.name)
.collect::<Vec<_>>();
let models = self.map_provider_model_ids(model_ids);
Expand Down Expand Up @@ -726,14 +738,17 @@ impl ModelsManager {
&self,
catalog: &'a HashMap<String, ModelsDevProvider>,
) -> Option<(&'a str, &'a ModelsDevProvider)> {
let normalized_name = Self::normalize_provider_key(&self.provider.name);
if let Some((provider_id, provider)) = catalog.get_key_value(&normalized_name) {
return Some((provider_id.as_str(), provider));
for provider_alias in self.models_dev_provider_aliases() {
if let Some((provider_id, provider)) = catalog.get_key_value(&provider_alias) {
return Some((provider_id.as_str(), provider));
}
}
if let Some((provider_id, provider)) = catalog
.iter()
.find(|(_, provider)| Self::normalize_provider_key(&provider.name) == normalized_name)
{
if let Some((provider_id, provider)) = catalog.iter().find(|(_, provider)| {
let normalized_provider_name = Self::normalize_provider_key(&provider.name);
self.models_dev_provider_aliases()
.iter()
.any(|alias| alias == &normalized_provider_name)
}) {
return Some((provider_id.as_str(), provider));
}

Expand Down Expand Up @@ -761,6 +776,26 @@ impl ModelsManager {
Some((first_match.0.as_str(), first_match.1))
}

fn models_dev_provider_aliases(&self) -> Vec<String> {
let normalized_provider_name = Self::normalize_provider_key(&self.provider.name);
let mut aliases = vec![normalized_provider_name.clone()];
let azure_named_provider = normalized_provider_name
.split('-')
.collect::<Vec<_>>()
.windows(2)
.any(|window| window == ["azure", "openai"]);
if (azure_named_provider
|| is_azure_responses_wire_base_url(
&self.provider.name,
self.provider.base_url.as_deref(),
))
&& !aliases.iter().any(|alias| alias == "azure")
{
aliases.push("azure".to_string());
}
aliases
}

fn map_models_dev_provider(&self, provider: &ModelsDevProvider) -> Vec<ModelInfo> {
let mut metadata_by_slug: HashMap<String, &ModelsDevModel> = HashMap::new();
let mut model_ids = provider
Expand Down Expand Up @@ -807,7 +842,7 @@ impl ModelsManager {
fn extract_host_from_url(input: &str) -> Option<String> {
reqwest::Url::parse(input)
.ok()
.and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
.and_then(|url| url.host_str().map(str::to_ascii_lowercase))
}

fn ollama_tags_url(base_url: &str) -> String {
Expand Down Expand Up @@ -2363,6 +2398,72 @@ mod tests {
);
}

#[tokio::test]
async fn models_dev_provider_match_accepts_azure_openai_alias() {
let models_dev_server = MockServer::start().await;
let _models_dev = wiremock::Mock::given(method("GET"))
.and(path("/api.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"azure": {
"id": "azure",
"name": "Azure",
"models": {
"azure-model-a": {
"id": "azure-model-a",
"name": "Azure Model A",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"limit": {"context": 128000, "output": 4096},
"options": {}
}
}
}
})))
.expect(1)
.mount_as_scoped(&models_dev_server)
.await;

let codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused"));
let provider = ModelProviderInfo {
name: "Azure OpenAI".to_string(),
base_url: Some("http://127.0.0.1:9/openai".to_string()),
env_key: Some("AZURE_OPENAI_API_KEY".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: Some(
[("api-version".to_string(), "2025-04-01-preview".to_string())]
.into_iter()
.collect(),
),
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
requires_openai_auth: false,
supports_websockets: false,
};
let manager = ModelsManager::with_provider_and_models_dev_url_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
format!("{}/api.json", models_dev_server.uri()),
);

let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await;
assert!(
available
.iter()
.any(|preset| preset.model == "azure-model-a"),
"expected Azure OpenAI alias to match models.dev Azure provider"
);
}

#[tokio::test]
async fn non_openai_provider_falls_back_to_provider_models_when_models_dev_has_no_match() {
let models_dev_server = MockServer::start().await;
Expand Down
27 changes: 27 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ impl App {
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
let mut overrides = self.harness_overrides.clone();
overrides.cwd = Some(cwd.clone());
overrides.config_profile = self.active_profile.clone().or(overrides.config_profile);
let cwd_display = cwd.display().to_string();
ConfigBuilder::default()
.codex_home(self.config.codex_home.clone())
Expand Down Expand Up @@ -6736,6 +6737,32 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn rebuild_config_for_cwd_preserves_active_profile() -> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
app.active_profile = Some("copilot".to_string());
std::fs::write(
codex_home.path().join("config.toml"),
r#"
model_provider = "openai"
model = "gpt-5.4"

[profiles.copilot]
model_provider = "github-copilot"
model = "claude-opus-4.6"
"#,
)?;

let rebuilt = app.rebuild_config_for_cwd(app.config.cwd.clone()).await?;

assert_eq!(rebuilt.active_profile.as_deref(), Some("copilot"));
assert_eq!(rebuilt.model_provider_id, "github-copilot");
assert_eq!(rebuilt.model.as_deref(), Some("claude-opus-4.6"));
Ok(())
}

#[tokio::test]
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
Expand Down
Loading
Loading