mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(models): add GPT-5.5 support
This commit is contained in:
Generated
+3
@@ -1651,6 +1651,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "code-version"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-experimental-api-macros"
|
||||
|
||||
@@ -6,3 +6,6 @@ build = "build.rs"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
// Compile-time embedded version string.
|
||||
// Prefer the CODE_VERSION provided by CI; fall back to the package
|
||||
// version for local builds.
|
||||
@@ -11,23 +14,36 @@ pub const CODE_VERSION: &str = {
|
||||
};
|
||||
|
||||
const ANNOUNCEMENT_TIP: &str = include_str!("../../../announcement_tip.toml");
|
||||
const MODELS_MANIFEST: &str = include_str!("../../../codex-rs/models-manager/models.json");
|
||||
pub const MIN_WIRE_COMPAT_VERSION_FALLBACK: &str = "0.101.0";
|
||||
|
||||
static MIN_WIRE_COMPAT_VERSION: LazyLock<&'static str> = LazyLock::new(|| {
|
||||
let extracted = extract_max_semver(ANNOUNCEMENT_TIP).unwrap_or(MIN_WIRE_COMPAT_VERSION_FALLBACK);
|
||||
let Some(extracted_triplet) = parse_semver_triplet(extracted) else {
|
||||
return MIN_WIRE_COMPAT_VERSION_FALLBACK;
|
||||
static MIN_WIRE_COMPAT_VERSION: LazyLock<String> = LazyLock::new(|| {
|
||||
let mut minimum = MIN_WIRE_COMPAT_VERSION_FALLBACK.to_string();
|
||||
|
||||
if let Some(extracted) = extract_max_semver(ANNOUNCEMENT_TIP) {
|
||||
minimum = max_semver(&minimum, extracted).to_string();
|
||||
}
|
||||
|
||||
minimum
|
||||
});
|
||||
|
||||
static MODEL_MINIMUM_CLIENT_VERSIONS: LazyLock<HashMap<String, String>> =
|
||||
LazyLock::new(|| parse_model_minimum_client_versions(MODELS_MANIFEST));
|
||||
|
||||
fn max_semver<'a>(current: &'a str, candidate: &'a str) -> &'a str {
|
||||
let Some(current_triplet) = parse_semver_triplet(current) else {
|
||||
return candidate;
|
||||
};
|
||||
let Some(fallback_triplet) = parse_semver_triplet(MIN_WIRE_COMPAT_VERSION_FALLBACK) else {
|
||||
return extracted;
|
||||
let Some(candidate_triplet) = parse_semver_triplet(candidate) else {
|
||||
return current;
|
||||
};
|
||||
|
||||
if extracted_triplet < fallback_triplet {
|
||||
MIN_WIRE_COMPAT_VERSION_FALLBACK
|
||||
if candidate_triplet > current_triplet {
|
||||
candidate
|
||||
} else {
|
||||
extracted
|
||||
current
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
|
||||
let trimmed = version.trim().trim_start_matches('v');
|
||||
@@ -71,6 +87,33 @@ fn extract_max_semver(input: &'static str) -> Option<&'static str> {
|
||||
max.map(|(_, version)| version)
|
||||
}
|
||||
|
||||
fn parse_model_minimum_client_versions(input: &str) -> HashMap<String, String> {
|
||||
let Ok(root) = serde_json::from_str::<Value>(input) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Some(models) = root.get("models").and_then(Value::as_array) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let mut versions = HashMap::new();
|
||||
|
||||
for model in models {
|
||||
let Some(slug) = model.get("slug").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(candidate) = model.get("minimal_client_version").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if parse_semver_triplet(candidate).is_none() {
|
||||
continue;
|
||||
};
|
||||
|
||||
versions.insert(slug.to_ascii_lowercase(), candidate.to_string());
|
||||
}
|
||||
|
||||
versions
|
||||
}
|
||||
|
||||
fn wire_compatible_version_for<'a>(version: &'a str, minimum: &'a str) -> &'a str {
|
||||
let Some(version_triplet) = parse_semver_triplet(version) else {
|
||||
return version;
|
||||
@@ -93,7 +136,7 @@ pub fn version() -> &'static str {
|
||||
|
||||
#[inline]
|
||||
pub fn min_wire_compat_version() -> &'static str {
|
||||
*MIN_WIRE_COMPAT_VERSION
|
||||
MIN_WIRE_COMPAT_VERSION.as_str()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -101,6 +144,17 @@ pub fn wire_compatible_version() -> &'static str {
|
||||
wire_compatible_version_for(CODE_VERSION, min_wire_compat_version())
|
||||
}
|
||||
|
||||
pub fn wire_compatible_version_for_model(model: &str) -> String {
|
||||
let canonical_model = model.rsplit('/').next().unwrap_or(model).trim();
|
||||
let Some(required_version) = MODEL_MINIMUM_CLIENT_VERSIONS
|
||||
.get(&canonical_model.to_ascii_lowercase())
|
||||
else {
|
||||
return wire_compatible_version().to_string();
|
||||
};
|
||||
|
||||
max_semver(wire_compatible_version(), required_version).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -165,6 +219,35 @@ mod tests {
|
||||
assert!(configured >= fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_model_minimum_client_versions_extracts_versions() {
|
||||
let input = r#"{
|
||||
"models": [
|
||||
{"slug": "gpt-5.4", "minimal_client_version": "0.98.0"},
|
||||
{"slug": "gpt-5.5", "minimal_client_version": "0.124.0"},
|
||||
{"slug": "legacy", "minimal_client_version": "0.0.1"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
assert_eq!(
|
||||
parse_model_minimum_client_versions(input).get("gpt-5.5"),
|
||||
Some(&"0.124.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_compatible_version_for_model_raises_for_gpt_5_5() {
|
||||
assert_eq!(wire_compatible_version_for_model("gpt-5.5"), "0.124.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_compatible_version_for_model_uses_base_version_for_unknown_models() {
|
||||
assert_eq!(
|
||||
wire_compatible_version_for_model("unknown-model"),
|
||||
wire_compatible_version()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_semver_triplet_rejects_four_component_versions() {
|
||||
assert_eq!(parse_semver_triplet("1.2.3.4"), None);
|
||||
|
||||
@@ -49,6 +49,37 @@ const ALL_TEXT_VERBOSITY: &[TextVerbosityConfig] = &[
|
||||
|
||||
static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
|
||||
vec![
|
||||
ModelPreset {
|
||||
id: "gpt-5.5".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
display_name: "GPT-5.5".to_string(),
|
||||
description: "Frontier model for complex coding, research, and real-world work."
|
||||
.to_string(),
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
supported_reasoning_efforts: vec![
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Low,
|
||||
description: "Fast responses with lighter reasoning".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: "Balances speed and reasoning depth for everyday tasks".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High,
|
||||
description: "Greater reasoning depth for complex problems".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::XHigh,
|
||||
description: "Extra high reasoning depth for complex problems".to_string(),
|
||||
},
|
||||
],
|
||||
supported_text_verbosity: ALL_TEXT_VERBOSITY,
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
pro_only: false,
|
||||
show_in_picker: true,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5.4".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
@@ -673,6 +704,12 @@ mod tests {
|
||||
assert!(presets.iter().any(|preset| preset.id == "gpt-5.4-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_5_available_for_chatgpt_auth() {
|
||||
let presets = builtin_model_presets(Some(AuthMode::Chatgpt), true);
|
||||
assert!(presets.iter().any(|preset| preset.id == "gpt-5.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_reasoning_effort_downgrades_to_supported_level() {
|
||||
let clamped = clamp_reasoning_effort_for_model(
|
||||
|
||||
@@ -46,6 +46,7 @@ pub(crate) async fn stream_chat_completions(
|
||||
model_slug: &str,
|
||||
client: &reqwest::Client,
|
||||
provider: &ModelProviderInfo,
|
||||
responses_originator_header: &str,
|
||||
debug_logger: &Arc<Mutex<DebugLogger>>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
otel_event_manager: Option<OtelEventManager>,
|
||||
@@ -396,6 +397,10 @@ pub(crate) async fn stream_chat_completions(
|
||||
let base_auth = auth_manager.as_ref().and_then(|m| m.auth());
|
||||
let auth = provider.effective_auth(&base_auth).await?;
|
||||
let mut req_builder = provider.create_request_builder_with_auth(client, &auth).await?;
|
||||
req_builder = req_builder.headers(crate::default_client::requested_model_headers(
|
||||
Some(responses_originator_header),
|
||||
model_slug,
|
||||
));
|
||||
|
||||
if let Some(auth) = auth.as_ref() {
|
||||
if auth.mode.is_chatgpt() {
|
||||
|
||||
@@ -365,6 +365,17 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_requested_model_headers(
|
||||
&self,
|
||||
req_builder: reqwest::RequestBuilder,
|
||||
model: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
req_builder.headers(crate::default_client::requested_model_headers(
|
||||
Some(self.config.responses_originator_header.as_str()),
|
||||
model,
|
||||
))
|
||||
}
|
||||
|
||||
fn current_reasoning_param(
|
||||
&self,
|
||||
family: &ModelFamily,
|
||||
@@ -631,6 +642,7 @@ impl ModelClient {
|
||||
model_slug,
|
||||
&self.client,
|
||||
&self.provider,
|
||||
self.config.responses_originator_header.as_str(),
|
||||
&self.debug_logger,
|
||||
self.auth_manager.clone(),
|
||||
self.otel_event_manager.clone(),
|
||||
@@ -821,6 +833,7 @@ impl ModelClient {
|
||||
url,
|
||||
)
|
||||
.await?;
|
||||
req_builder = self.apply_requested_model_headers(req_builder, request_model);
|
||||
|
||||
let has_beta_header = req_builder
|
||||
.try_clone()
|
||||
@@ -1293,6 +1306,7 @@ impl ModelClient {
|
||||
.provider
|
||||
.create_request_builder_with_auth(&self.client, &auth)
|
||||
.await?;
|
||||
req_builder = self.apply_requested_model_headers(req_builder, request_model);
|
||||
|
||||
let has_beta_header = req_builder
|
||||
.try_clone()
|
||||
@@ -1927,6 +1941,7 @@ impl ModelClient {
|
||||
.provider
|
||||
.create_compact_request_builder_with_auth(&self.client, &auth)
|
||||
.await?;
|
||||
request = self.apply_requested_model_headers(request, model_slug);
|
||||
|
||||
// Ensure Responses API beta header is present for compact calls. Mirror the
|
||||
// streaming path: use the public "responses=v1" header for the public OpenAI
|
||||
|
||||
@@ -3517,6 +3517,30 @@ context_mode = "1m"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mode_one_m_is_inert_for_gpt_5_5() -> anyhow::Result<()> {
|
||||
let code_home = TempDir::new()?;
|
||||
let cfg = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
model = "gpt-5.5"
|
||||
context_mode = "1m"
|
||||
"#,
|
||||
)?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides {
|
||||
cwd: Some(code_home.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
code_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.context_mode, Some(ContextMode::OneM));
|
||||
assert_eq!(config.model_context_window, Some(272_000));
|
||||
assert_eq!(config.model_auto_compact_token_limit, Some(244_800));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mode_auto_expands_gpt_5_4_context() -> anyhow::Result<()> {
|
||||
let code_home = TempDir::new()?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex;
|
||||
@@ -94,11 +95,49 @@ pub fn get_code_user_agent(originator_override: Option<&str>) -> String {
|
||||
get_code_user_agent_with_suffix(originator_override, suffix.as_deref())
|
||||
}
|
||||
|
||||
pub fn get_code_user_agent_for_model(originator_override: Option<&str>, model: &str) -> String {
|
||||
let suffix = USER_AGENT_SUFFIX
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone());
|
||||
let build_version = code_version::wire_compatible_version_for_model(model);
|
||||
get_code_user_agent_with_version(originator_override, suffix.as_deref(), &build_version)
|
||||
}
|
||||
|
||||
pub fn requested_model_headers(originator_override: Option<&str>, model: &str) -> HeaderMap {
|
||||
let requested_version = code_version::wire_compatible_version_for_model(model);
|
||||
let user_agent = get_code_user_agent_for_model(originator_override, model);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"version",
|
||||
HeaderValue::from_str(&requested_version)
|
||||
.expect("requested model version should be a valid header value"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
HeaderValue::from_str(&user_agent)
|
||||
.expect("requested model user-agent should be a valid header value"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn get_code_user_agent_with_suffix(
|
||||
originator_override: Option<&str>,
|
||||
user_agent_suffix: Option<&str>,
|
||||
) -> String {
|
||||
let build_version = code_version::wire_compatible_version();
|
||||
get_code_user_agent_with_version(
|
||||
originator_override,
|
||||
user_agent_suffix,
|
||||
code_version::wire_compatible_version(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_code_user_agent_with_version(
|
||||
originator_override: Option<&str>,
|
||||
user_agent_suffix: Option<&str>,
|
||||
build_version: &str,
|
||||
) -> String {
|
||||
let os_info = os_info::get();
|
||||
let originator_value = originator_override
|
||||
.map(str::to_string)
|
||||
@@ -249,6 +288,43 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_model_headers_replace_version_and_user_agent() {
|
||||
with_originator_env_cleared(|| {
|
||||
let client = reqwest::Client::new();
|
||||
let mut original_headers = HeaderMap::new();
|
||||
original_headers.insert("version", HeaderValue::from_static("0.101.0"));
|
||||
original_headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
HeaderValue::from_static("old-originator/0.101.0"),
|
||||
);
|
||||
|
||||
let request = client
|
||||
.get("https://example.com")
|
||||
.headers(original_headers)
|
||||
.headers(requested_model_headers(Some("test_originator"), "gpt-5.5"))
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
let version_values: Vec<_> = request
|
||||
.headers()
|
||||
.get_all("version")
|
||||
.iter()
|
||||
.map(|value| value.to_str().expect("version header"))
|
||||
.collect();
|
||||
assert_eq!(version_values, vec!["0.124.0"]);
|
||||
|
||||
let ua_values: Vec<_> = request
|
||||
.headers()
|
||||
.get_all(reqwest::header::USER_AGENT)
|
||||
.iter()
|
||||
.map(|value| value.to_str().expect("user-agent header"))
|
||||
.collect();
|
||||
assert_eq!(ua_values.len(), 1);
|
||||
assert!(ua_values[0].starts_with("test_originator/0.124.0"));
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_client_sets_default_headers() {
|
||||
use wiremock::Mock;
|
||||
|
||||
@@ -651,19 +651,23 @@ impl ModelSelectionView {
|
||||
});
|
||||
|
||||
let context_selected = matches!(self.entries().get(self.selected_index), Some(EntryKind::ContextMode));
|
||||
let context_status = match self.current_context_mode {
|
||||
Some(ContextMode::OneM) => "enabled",
|
||||
Some(ContextMode::Auto) => "auto",
|
||||
Some(ContextMode::Disabled) | None => "disabled",
|
||||
};
|
||||
let context_available = self.supports_extended_context();
|
||||
let context_status = if context_available {
|
||||
match self.current_context_mode {
|
||||
Some(ContextMode::OneM) => "enabled",
|
||||
Some(ContextMode::Auto) => "auto",
|
||||
Some(ContextMode::Disabled) | None => "disabled",
|
||||
}
|
||||
} else {
|
||||
"unavailable"
|
||||
};
|
||||
let mut context_label_style = Style::default().fg(crate::colors::text());
|
||||
if context_selected {
|
||||
context_label_style = context_label_style
|
||||
.bg(crate::colors::selection())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if self.current_context_mode.is_some() {
|
||||
if context_available && self.current_context_mode.is_some() {
|
||||
context_label_style = context_label_style.fg(crate::colors::success());
|
||||
}
|
||||
if !context_available {
|
||||
@@ -898,15 +902,7 @@ impl ModelSelectionView {
|
||||
}
|
||||
|
||||
fn compare_presets(a: &FlatPreset, b: &FlatPreset) -> Ordering {
|
||||
let model_rank = Self::model_rank(&a.model).cmp(&Self::model_rank(&b.model));
|
||||
if model_rank != Ordering::Equal {
|
||||
return model_rank;
|
||||
}
|
||||
|
||||
let model_name_rank = a
|
||||
.model
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.model.to_ascii_lowercase());
|
||||
let model_name_rank = Self::compare_model_names(&a.model, &b.model);
|
||||
if model_name_rank != Ordering::Equal {
|
||||
return model_name_rank;
|
||||
}
|
||||
@@ -919,34 +915,53 @@ impl ModelSelectionView {
|
||||
a.label.cmp(&b.label)
|
||||
}
|
||||
|
||||
fn model_rank(model: &str) -> u8 {
|
||||
if model.eq_ignore_ascii_case("gpt-5.4") {
|
||||
0
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.4-mini") {
|
||||
1
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.3-codex") {
|
||||
2
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.3-codex-spark") {
|
||||
3
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.2-codex") {
|
||||
4
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.2") {
|
||||
5
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.1-codex-max") {
|
||||
6
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.1-codex") {
|
||||
7
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.1-codex-mini") {
|
||||
8
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.1") {
|
||||
9
|
||||
} else {
|
||||
10
|
||||
fn compare_model_names(a: &str, b: &str) -> Ordering {
|
||||
let a_lower = a.to_ascii_lowercase();
|
||||
let b_lower = b.to_ascii_lowercase();
|
||||
match (
|
||||
Self::parse_model_version_components(&a_lower),
|
||||
Self::parse_model_version_components(&b_lower),
|
||||
) {
|
||||
(Some(a_components), Some(b_components)) => {
|
||||
let component_rank = b_components.cmp(&a_components);
|
||||
if component_rank != Ordering::Equal {
|
||||
return component_rank;
|
||||
}
|
||||
a_lower.cmp(&b_lower)
|
||||
}
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => a_lower.cmp(&b_lower),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_model_version_components(model: &str) -> Option<Vec<u32>> {
|
||||
let canonical = model.rsplit('/').next().unwrap_or(model);
|
||||
let mut components = Vec::new();
|
||||
|
||||
for segment in canonical.split('-') {
|
||||
let first = segment.chars().next()?;
|
||||
if !first.is_ascii_digit() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for part in segment.split('.') {
|
||||
if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
components.push(part.parse().ok()?);
|
||||
}
|
||||
|
||||
return (!components.is_empty()).then_some(components);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn model_description(model: &str) -> Option<&'static str> {
|
||||
if model.eq_ignore_ascii_case("gpt-5.4") {
|
||||
if model.eq_ignore_ascii_case("gpt-5.5") {
|
||||
Some("Frontier model for complex coding, research, and real-world work.")
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.4") {
|
||||
Some("Brings together flagship reasoning, coding, and tool use in a single frontier model.")
|
||||
} else if model.eq_ignore_ascii_case("gpt-5.4-mini") {
|
||||
Some("Smaller GPT-5.4 variant tuned for faster coding loops.")
|
||||
@@ -1160,6 +1175,17 @@ mod tests {
|
||||
rows
|
||||
}
|
||||
|
||||
fn sorted_model_order(view: &ModelSelectionView) -> Vec<String> {
|
||||
let mut order = Vec::new();
|
||||
for idx in view.sorted_indices() {
|
||||
let model = view.flat_presets[idx].model.clone();
|
||||
if order.last() != Some(&model) {
|
||||
order.push(model);
|
||||
}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_selection_scrolls_selected_model_into_view() {
|
||||
let presets = (0..12).map(|i| make_preset(&format!("model-{i:02}"))).collect();
|
||||
@@ -1167,7 +1193,7 @@ mod tests {
|
||||
|
||||
let mut view = ModelSelectionView::new(
|
||||
presets,
|
||||
"model-00".to_string(),
|
||||
"model-11".to_string(),
|
||||
ReasoningEffort::Low,
|
||||
None,
|
||||
None,
|
||||
@@ -1176,7 +1202,22 @@ mod tests {
|
||||
AppEventSender::new(tx),
|
||||
);
|
||||
|
||||
for _ in 0..13 {
|
||||
let last_model = sorted_model_order(&view)
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("at least one model");
|
||||
|
||||
for _ in 0..view.entries().len() {
|
||||
let selected_entry = view
|
||||
.entries()
|
||||
.get(view.selected_index)
|
||||
.copied()
|
||||
.expect("selected entry");
|
||||
if let EntryKind::Preset(idx) = selected_entry
|
||||
&& view.flat_presets[idx].model == last_model
|
||||
{
|
||||
break;
|
||||
}
|
||||
let _ = view.handle_key_event_direct(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
@@ -1195,9 +1236,19 @@ mod tests {
|
||||
height,
|
||||
}, &mut buf);
|
||||
|
||||
let lines = buffer_body_lines(&buf, width, height);
|
||||
let has_last_model = lines.iter().any(|line| line.contains("MODEL-11"));
|
||||
assert!(has_last_model);
|
||||
let selected_entry = view
|
||||
.entries()
|
||||
.get(view.selected_index)
|
||||
.copied()
|
||||
.expect("selected entry");
|
||||
|
||||
assert!(matches!(selected_entry, EntryKind::Preset(_)));
|
||||
assert_eq!(view.flat_presets[match selected_entry {
|
||||
EntryKind::Preset(idx) => idx,
|
||||
_ => unreachable!("selected entry should be a preset"),
|
||||
}]
|
||||
.model, last_model);
|
||||
assert!(view.scroll_top.get() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1248,7 +1299,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_selection_prioritizes_gpt_5_4_and_shows_fast_mode_toggle() {
|
||||
fn model_selection_prioritizes_higher_versions_and_shows_fast_mode_toggle() {
|
||||
let mut gpt_5_5 = make_preset("gpt-5.5");
|
||||
gpt_5_5.supported_reasoning_efforts = vec![
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Low.into(),
|
||||
description: "Fast responses with lighter reasoning".to_string(),
|
||||
},
|
||||
ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::High.into(),
|
||||
description: "Maximizes reasoning depth".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut gpt_5_4 = make_preset("gpt-5.4");
|
||||
gpt_5_4.supported_reasoning_efforts = vec![
|
||||
ReasoningEffortPreset {
|
||||
@@ -1278,14 +1341,15 @@ mod tests {
|
||||
make_preset("gpt-5.3-codex-spark"),
|
||||
gpt_5_4_mini,
|
||||
gpt_5_4,
|
||||
gpt_5_5,
|
||||
];
|
||||
let (tx, _rx) = mpsc::channel::<AppEvent>();
|
||||
let view = ModelSelectionView::new(
|
||||
presets,
|
||||
"gpt-5.4".to_string(),
|
||||
"gpt-5.5".to_string(),
|
||||
ReasoningEffort::Low,
|
||||
Some(ServiceTier::Fast),
|
||||
Some(ContextMode::OneM),
|
||||
None,
|
||||
false,
|
||||
ModelSelectionTarget::Session,
|
||||
AppEventSender::new(tx),
|
||||
@@ -1306,25 +1370,27 @@ mod tests {
|
||||
height,
|
||||
}, &mut buf);
|
||||
|
||||
let order = sorted_model_order(&view);
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![
|
||||
"gpt-5.5".to_string(),
|
||||
"gpt-5.4".to_string(),
|
||||
"gpt-5.4-mini".to_string(),
|
||||
"gpt-5.3-codex".to_string(),
|
||||
"gpt-5.3-codex-spark".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let lines = buffer_body_lines(&buf, width, height);
|
||||
let visible = lines.join("\n");
|
||||
let gpt_5_4_idx = visible
|
||||
.find("GPT-5.4")
|
||||
.expect("gpt-5.4 header");
|
||||
let gpt_5_4_mini_idx = visible
|
||||
.find("GPT-5.4-Mini")
|
||||
.expect("gpt-5.4-mini header");
|
||||
let gpt_5_3_idx = visible
|
||||
.find("GPT-5.3-Codex")
|
||||
.expect("gpt-5.3-codex header");
|
||||
|
||||
assert!(gpt_5_4_idx < gpt_5_4_mini_idx);
|
||||
assert!(gpt_5_4_mini_idx < gpt_5_3_idx);
|
||||
assert!(visible.contains("Mode Settings"));
|
||||
assert!(visible.contains("Fast Mode: enabled"));
|
||||
assert!(visible.contains("1M Context: enabled"));
|
||||
assert!(visible.contains("1M Context: unavailable"));
|
||||
assert!(visible.contains("Fast mode speeds up replies."));
|
||||
assert!(visible.contains("1M Context is available on supported models."));
|
||||
assert!(visible.contains(
|
||||
"Unavailable for this model. Saved settings apply automatically on supported models."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1443,7 +1509,55 @@ mod tests {
|
||||
}, &mut buf);
|
||||
|
||||
let lines = buffer_body_lines(&buf, width, height);
|
||||
assert!(lines.iter().any(|line| line.contains("1M Context: auto")));
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("1M Context: unavailable")),
|
||||
"expected unsupported model to show unavailable context, got:\n{}",
|
||||
lines.join("\n")
|
||||
);
|
||||
assert!(lines.iter().any(|line| {
|
||||
line.contains("Unavailable for this model. Saved settings apply automatically on supported models.")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_selection_marks_gpt_5_5_context_unavailable() {
|
||||
let presets = vec![make_preset("gpt-5.5")];
|
||||
let (tx, _rx) = mpsc::channel::<AppEvent>();
|
||||
let view = ModelSelectionView::new(
|
||||
presets,
|
||||
"gpt-5.5".to_string(),
|
||||
ReasoningEffort::Low,
|
||||
None,
|
||||
Some(ContextMode::OneM),
|
||||
false,
|
||||
ModelSelectionTarget::Session,
|
||||
AppEventSender::new(tx),
|
||||
);
|
||||
|
||||
let width = 100;
|
||||
let height = 18;
|
||||
let mut buf = ratatui::buffer::Buffer::empty(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
view.render(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
&mut buf,
|
||||
);
|
||||
|
||||
let lines = buffer_body_lines(&buf, width, height);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains("1M Context: unavailable")),
|
||||
"expected gpt-5.5 to show unavailable context, got:\n{}",
|
||||
lines.join("\n")
|
||||
);
|
||||
assert!(lines.iter().any(|line| {
|
||||
line.contains("Unavailable for this model. Saved settings apply automatically on supported models.")
|
||||
}));
|
||||
|
||||
@@ -48,6 +48,7 @@ use code_core::plan_tool::{PlanItemArg, StepStatus, UpdatePlanArgs};
|
||||
use code_core::model_family::derive_default_model_family;
|
||||
use code_core::model_family::find_family_for_model;
|
||||
use code_core::model_family::resolve_context_mode_limits;
|
||||
use code_core::model_family::supports_extended_context;
|
||||
use code_core::account_usage::{
|
||||
self,
|
||||
RateLimitWarningScope,
|
||||
@@ -23020,14 +23021,15 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
}
|
||||
|
||||
fn curated_model_presets(presets: Vec<ModelPreset>) -> Vec<ModelPreset> {
|
||||
const MODEL_PICKER_ORDER: [&str; 4] = [
|
||||
const MODEL_PICKER_ORDER: [&str; 5] = [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark",
|
||||
];
|
||||
|
||||
let curated: Vec<ModelPreset> = MODEL_PICKER_ORDER
|
||||
let mut curated: Vec<ModelPreset> = MODEL_PICKER_ORDER
|
||||
.into_iter()
|
||||
.filter_map(|model| {
|
||||
presets.iter().find(|preset| {
|
||||
@@ -23038,6 +23040,21 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let curated_slugs: std::collections::HashSet<String> = curated
|
||||
.iter()
|
||||
.flat_map(|preset| {
|
||||
[
|
||||
preset.id.to_ascii_lowercase(),
|
||||
preset.model.to_ascii_lowercase(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
curated.extend(presets.iter().filter(|preset| {
|
||||
!curated_slugs.contains(&preset.id.to_ascii_lowercase())
|
||||
&& !curated_slugs.contains(&preset.model.to_ascii_lowercase())
|
||||
}).cloned());
|
||||
|
||||
if curated.is_empty() { presets } else { curated }
|
||||
}
|
||||
|
||||
@@ -23536,8 +23553,19 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
self.refresh_settings_overview_rows();
|
||||
|
||||
let status = match self.config.context_mode {
|
||||
Some(ContextMode::OneM) => "1M context enabled.".to_string(),
|
||||
Some(ContextMode::Auto) => "Auto Context enabled.".to_string(),
|
||||
Some(ContextMode::OneM) if supports_extended_context(&self.config.model) => {
|
||||
"1M context enabled.".to_string()
|
||||
}
|
||||
Some(ContextMode::OneM) => {
|
||||
"1M context is unavailable for this model and will only apply on supported models."
|
||||
.to_string()
|
||||
}
|
||||
Some(ContextMode::Auto) if supports_extended_context(&self.config.model) => {
|
||||
"Auto Context enabled.".to_string()
|
||||
}
|
||||
Some(ContextMode::Auto) => {
|
||||
"Auto Context will only expand on supported models.".to_string()
|
||||
}
|
||||
Some(ContextMode::Disabled) | None => "Context mode disabled.".to_string(),
|
||||
};
|
||||
self.bottom_pane.flash_footer_notice(status);
|
||||
@@ -31285,6 +31313,76 @@ use code_core::protocol::OrderMeta;
|
||||
assert_eq!(ids, vec!["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_model_presets_include_gpt_5_5() {
|
||||
let presets = builtin_model_presets(Some(AuthMode::ChatGPT), true);
|
||||
assert!(presets.iter().any(|preset| preset.id == "gpt-5.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_model_presets_appends_new_remote_models() {
|
||||
let mut gpt_5_5 = builtin_model_presets(Some(AuthMode::ChatGPT), true)
|
||||
.into_iter()
|
||||
.find(|preset| preset.id == "gpt-5.4")
|
||||
.expect("expected gpt-5.4 builtin preset");
|
||||
gpt_5_5.id = "gpt-5.5".to_string();
|
||||
gpt_5_5.model = "gpt-5.5".to_string();
|
||||
gpt_5_5.display_name = "GPT-5.5".to_string();
|
||||
|
||||
let presets = vec![
|
||||
gpt_5_5,
|
||||
ModelPreset {
|
||||
id: "gpt-5.4".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
display_name: "gpt-5.4".to_string(),
|
||||
description: "Frontier flagship model.".to_string(),
|
||||
default_reasoning_effort: ReasoningEffort::Medium.into(),
|
||||
supported_reasoning_efforts: vec![ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium.into(),
|
||||
description: "balanced".to_string(),
|
||||
}],
|
||||
supported_text_verbosity: &[TextVerbosity::Medium],
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
pro_only: false,
|
||||
show_in_picker: true,
|
||||
},
|
||||
];
|
||||
|
||||
let curated = ChatWidget::curated_model_presets(presets);
|
||||
let ids: Vec<&str> = curated.iter().map(|preset| preset.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["gpt-5.5", "gpt-5.4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_model_presets_deduplicates_alias_slugs() {
|
||||
let canonical = ModelPreset {
|
||||
id: "alias/gpt-5.5".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
display_name: "GPT-5.5".to_string(),
|
||||
description: "Frontier flagship model.".to_string(),
|
||||
default_reasoning_effort: ReasoningEffort::Medium.into(),
|
||||
supported_reasoning_efforts: vec![ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium.into(),
|
||||
description: "balanced".to_string(),
|
||||
}],
|
||||
supported_text_verbosity: &[TextVerbosity::Medium],
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
pro_only: false,
|
||||
show_in_picker: true,
|
||||
};
|
||||
let duplicate = ModelPreset {
|
||||
id: "gpt-5.5".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
..canonical.clone()
|
||||
};
|
||||
|
||||
let curated = ChatWidget::curated_model_presets(vec![canonical, duplicate]);
|
||||
let ids: Vec<&str> = curated.iter().map(|preset| preset.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["alias/gpt-5.5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_command_accepts_supported_non_curated_builtin_model() {
|
||||
let _runtime_guard = enter_test_runtime_guard();
|
||||
|
||||
Reference in New Issue
Block a user