feat(models): add gpt-5.1-codex-max default and migration flow

This commit is contained in:
James Peter
2025-11-21 00:52:37 +10:00
parent cee599e0d0
commit 8a97572fe5
30 changed files with 1390 additions and 320 deletions
+1
View File
@@ -1132,6 +1132,7 @@ dependencies = [
"code-app-server-protocol",
"code-core",
"code-protocol",
"once_cell",
"serde",
"toml 0.9.8",
]
+1
View File
@@ -11,6 +11,7 @@ clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
code-core = { workspace = true }
code-protocol = { workspace = true }
code-app-server-protocol = { workspace = true }
once_cell = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }
+246 -94
View File
@@ -1,120 +1,272 @@
use std::collections::HashMap;
use code_app_server_protocol::AuthMode;
use code_core::protocol_config_types::ReasoningEffort;
use once_cell::sync::Lazy;
/// A simple preset pairing a model slug with a reasoning effort.
pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt";
pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str =
"hide_gpt-5.1-codex-max_migration_prompt";
/// A reasoning effort option surfaced for a model.
#[derive(Debug, Clone, Copy)]
pub struct ModelPreset {
/// Stable identifier for the preset.
pub id: &'static str,
/// Display label shown in UIs.
pub label: &'static str,
/// Short human description shown next to the label in UIs.
pub struct ReasoningEffortPreset {
pub effort: ReasoningEffort,
pub description: &'static str,
/// Model slug (e.g., "gpt-5.1").
pub model: &'static str,
/// Reasoning effort to apply for this preset.
pub effort: Option<ReasoningEffort>,
}
const PRESETS: &[ModelPreset] = &[
ModelPreset {
id: "gpt-5.1-codex-low",
label: "gpt-5.1-codex low",
description: "Fastest responses with limited reasoning",
model: "gpt-5.1-codex",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5.1-codex-medium",
label: "gpt-5.1-codex medium",
description: "Dynamically adjusts reasoning based on the task",
model: "gpt-5.1-codex",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5.1-codex-high",
label: "gpt-5.1-codex high",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5.1-codex",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5.1-codex-mini",
label: "gpt-5.1-codex-mini",
description: "Optimized for Code. Cheaper, faster, but less capable.",
model: "gpt-5.1-codex-mini",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5.1-codex-mini-high",
label: "gpt-5.1-codex-mini high",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5.1-codex-mini",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5.1-minimal",
label: "gpt-5.1 minimal",
description: "Fastest responses with little reasoning",
model: "gpt-5.1",
effort: Some(ReasoningEffort::Minimal),
},
ModelPreset {
id: "gpt-5.1-low",
label: "gpt-5.1 low",
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5.1",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5.1-medium",
label: "gpt-5.1 medium",
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5.1",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5.1-high",
label: "gpt-5.1 high",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5.1",
effort: Some(ReasoningEffort::High),
},
];
#[derive(Debug, Clone)]
pub struct ModelUpgrade {
pub id: &'static str,
pub reasoning_effort_mapping: Option<HashMap<ReasoningEffort, ReasoningEffort>>,
pub migration_config_key: &'static str,
}
/// Metadata describing a Code-supported model.
#[derive(Debug, Clone)]
pub struct ModelPreset {
pub id: &'static str,
pub model: &'static str,
pub display_name: &'static str,
pub description: &'static str,
pub default_reasoning_effort: ReasoningEffort,
pub supported_reasoning_efforts: &'static [ReasoningEffortPreset],
pub is_default: bool,
pub upgrade: Option<ModelUpgrade>,
pub show_in_picker: bool,
}
static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
vec![
ModelPreset {
id: "gpt-5.1-codex-max",
model: "gpt-5.1-codex-max",
display_name: "gpt-5.1-codex-max",
description: "Latest Codex-optimized flagship for deep and fast reasoning.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex problems",
},
ReasoningEffortPreset {
effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems",
},
],
is_default: true,
upgrade: None,
show_in_picker: true,
},
ModelPreset {
id: "gpt-5.1-codex",
model: "gpt-5.1-codex",
display_name: "gpt-5.1-codex",
description: "Optimized for Code.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: true,
},
ModelPreset {
id: "gpt-5.1-codex-mini",
model: "gpt-5.1-codex-mini",
display_name: "gpt-5.1-codex-mini",
description: "Optimized for Code. Cheaper, faster, but less capable.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: true,
},
ModelPreset {
id: "gpt-5.1",
model: "gpt-5.1",
display_name: "gpt-5.1",
description: "Broad world knowledge with strong general reasoning.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description:
"Balances speed with some reasoning; useful for straightforward queries and short explanations",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description:
"Provides a solid balance of reasoning depth and latency for general-purpose tasks",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: true,
},
// Deprecated GPT-5 variants kept for migrations / config compatibility.
ModelPreset {
id: "gpt-5-codex",
model: "gpt-5-codex",
display_name: "gpt-5-codex",
description: "Optimized for Code.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: false,
},
ModelPreset {
id: "gpt-5-codex-mini",
model: "gpt-5-codex-mini",
display_name: "gpt-5-codex-mini",
description: "Optimized for Code. Cheaper, faster, but less capable.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-mini",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: false,
},
ModelPreset {
id: "gpt-5",
model: "gpt-5",
display_name: "gpt-5",
description: "Broad world knowledge with strong general reasoning.",
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: &[
ReasoningEffortPreset {
effort: ReasoningEffort::Minimal,
description: "Fastest responses with little reasoning",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description:
"Balances speed with some reasoning; useful for straightforward queries and short explanations",
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description:
"Provides a solid balance of reasoning depth and latency for general-purpose tasks",
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems",
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max",
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
}),
show_in_picker: false,
},
]
});
pub fn builtin_model_presets(auth_mode: Option<AuthMode>) -> Vec<ModelPreset> {
let allow_codex_mini = matches!(auth_mode, Some(AuthMode::ChatGPT));
PRESETS
.iter()
.filter(|preset| allow_codex_mini || preset.model != "gpt-5.1-codex-mini")
.copied()
.filter(|preset| match auth_mode {
Some(AuthMode::ApiKey) => preset.show_in_picker && preset.id != "gpt-5.1-codex-max",
_ => preset.show_in_picker,
})
.cloned()
.collect()
}
pub fn all_model_presets() -> &'static Vec<ModelPreset> {
&PRESETS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chatgpt_accounts_include_codex_mini() {
let presets = builtin_model_presets(Some(AuthMode::ChatGPT));
assert!(presets
.iter()
.filter(|preset| preset.model == "gpt-5.1-codex-mini")
.count()
== 2);
fn only_one_default_model_is_configured() {
assert_eq!(PRESETS.iter().filter(|preset| preset.is_default).count(), 1);
}
#[test]
fn api_key_accounts_exclude_codex_mini() {
fn gpt_5_1_codex_max_hidden_for_api_key_auth() {
let presets = builtin_model_presets(Some(AuthMode::ApiKey));
assert!(!presets
assert!(presets
.iter()
.any(|preset| preset.model == "gpt-5.1-codex-mini"));
let presets = builtin_model_presets(None);
assert!(!presets
.iter()
.any(|preset| preset.model == "gpt-5.1-codex-mini"));
.all(|preset| preset.id != "gpt-5.1-codex-max"));
}
}
+117
View File
@@ -0,0 +1,117 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the `with_escalated_permissions` parameter with the boolean value true
- Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow final‑answer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5
-2
View File
@@ -318,8 +318,6 @@ For casual greetings, acknowledgements, or other one-off conversational messages
When using the shell, you must adhere to the following guidelines:
- The arguments to `shell` will be passed to execvp().
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
+6
View File
@@ -80,6 +80,7 @@ pub(crate) async fn stream_chat_completions(
last_emitted_role = Some("assistant")
}
ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"),
ResponseItem::CompactionSummary { .. } => last_emitted_role = Some("assistant"),
ResponseItem::Reasoning { .. } | ResponseItem::Other => {}
ResponseItem::CustomToolCall { .. } => {}
ResponseItem::CustomToolCallOutput { .. } => {}
@@ -207,6 +208,11 @@ pub(crate) async fn stream_chat_completions(
messages.push(json!({"role": role, "content": text}));
}
}
ResponseItem::CompactionSummary { .. } => {
// Compaction summaries are only meaningful to the Responses API; omit them
// when translating to Chat Completions.
continue;
}
ResponseItem::FunctionCall {
name,
arguments,
+110 -5
View File
@@ -85,6 +85,19 @@ struct Error {
resets_in_seconds: Option<u64>,
}
#[derive(Serialize)]
struct CompactHistoryRequest<'a> {
model: &'a str,
#[serde(borrow)]
input: &'a [ResponseItem],
instructions: String,
}
#[derive(Debug, Deserialize)]
struct CompactHistoryResponse {
output: Vec<ResponseItem>,
}
fn rate_limit_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
@@ -548,16 +561,11 @@ impl ModelClient {
req_builder = req_builder.header("OpenAI-Beta", beta_value);
}
// `Codex-Task-Type` differentiates traffic for caching; default to "standard" until
// task-specific dispatch is re-introduced.
let codex_task_type = "standard";
req_builder = req_builder
// Send `conversation_id`/`session_id` so the server can hit the prompt-cache.
.header("conversation_id", session_id_str.clone())
.header("session_id", session_id_str.clone())
.header(reqwest::header::ACCEPT, "text/event-stream")
.header("Codex-Task-Type", codex_task_type)
.json(&payload_json);
if let Some(auth) = auth.as_ref()
@@ -874,6 +882,103 @@ impl ModelClient {
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
if prompt.input.is_empty() {
return Ok(Vec::new());
}
let auth_manager = self.auth_manager.clone();
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let mut request = self
.provider
.create_compact_request_builder(&self.client, &auth)
.await?;
// Ensure Responses API beta header is present for compact calls. Mirror the
// streaming path: use the public "responses=v1" header for the public OpenAI
// endpoint and fall back to "responses=experimental" for other providers.
let has_beta_header = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta_header {
let beta_value = if self.provider.is_public_openai_responses_endpoint() {
RESPONSES_BETA_HEADER_V1
} else {
RESPONSES_BETA_HEADER_EXPERIMENTAL
};
request = request.header("OpenAI-Beta", beta_value);
}
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id()
{
request = request.header("chatgpt-account-id", account_id);
}
let instructions = prompt
.get_full_instructions(&self.config.model_family)
.into_owned();
let payload = CompactHistoryRequest {
model: &self.config.model,
input: &prompt.input,
instructions: instructions.clone(),
};
let payload_json = serde_json::json!({
"model": payload.model,
"input": payload.input,
"instructions": instructions,
});
request = request.json(&payload);
let header_snapshot = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
let mut request_id = String::new();
if let Ok(logger) = self.debug_logger.lock() {
let endpoint = self
.provider
.get_compact_url(&auth)
.unwrap_or_else(|| self.provider.get_full_url(&auth));
request_id = logger
.start_request_log(&endpoint, &payload_json, header_snapshot.as_ref(), Some("compact_remote"))
.unwrap_or_default();
}
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if let Ok(logger) = self.debug_logger.lock() {
let response_body: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({ "raw": body }));
let _ = logger.append_response_event(
&request_id,
"compact_response",
&serde_json::json!({
"status_code": status.as_u16(),
"body": response_body,
}),
);
let _ = logger.end_request_log(&request_id);
}
if !status.is_success() {
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
let CompactHistoryResponse { output } = serde_json::from_str(&body)?;
Ok(output)
}
}
#[derive(Debug, Deserialize, Serialize)]
+88 -5
View File
@@ -80,7 +80,9 @@ use chrono::Local;
use chrono::Utc;
pub mod compact;
pub mod compact_remote;
use self::compact::{build_compacted_history, collect_compaction_snippets};
use self::compact_remote::run_inline_remote_auto_compact_task;
/// Initial submission ID for session configuration
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
@@ -148,6 +150,7 @@ fn to_proto_reasoning_effort(effort: ReasoningEffortConfig) -> ProtoReasoningEff
ReasoningEffortConfig::Low => ProtoReasoningEffort::Low,
ReasoningEffortConfig::Medium => ProtoReasoningEffort::Medium,
ReasoningEffortConfig::High => ProtoReasoningEffort::High,
ReasoningEffortConfig::XHigh => ProtoReasoningEffort::XHigh,
ReasoningEffortConfig::None => ProtoReasoningEffort::Minimal,
}
}
@@ -1786,6 +1789,11 @@ impl Session {
state.current_task = Some(agent);
}
pub fn replace_history(&self, items: Vec<ResponseItem>) {
let mut state = self.state.lock().unwrap();
state.history.replace(items);
}
pub fn remove_task(&self, sub_id: &str) {
let mut state = self.state.lock().unwrap();
if let Some(agent) = &state.current_task {
@@ -5197,6 +5205,11 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
// Track if this is the first iteration - if so, include the initial input
let mut first_iteration = true;
// Track if we've done a proactive compaction in this iteration to prevent
// infinite loops. As long as compaction works well in getting us way below
// the token limit, we shouldn't need more than one compaction per iteration.
let mut did_proactive_compact_this_iteration = false;
loop {
// Note that pending_input would be something like a message the user
// submitted through the UI while the model was running. Though the UI
@@ -5392,6 +5405,60 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
}
}
// Check whether we should proactively compact before queuing follow-up work.
// Upstream codex-rs compacts as soon as usage hits the configured threshold,
// which keeps us from hitting hard context-window errors mid-session.
let limit = turn_context
.client
.get_auto_compact_token_limit()
.unwrap_or(i64::MAX);
let most_recent_usage_tokens: Option<i64> = {
let state = sess.state.lock().unwrap();
state.token_usage_info.as_ref().and_then(|info| {
info.last_token_usage.total_tokens.try_into().ok()
})
};
// auto_compact_token_limit is defined relative to a single turn's
// token usage (input + output). Using the cumulative total caused
// the limit check to stay tripped permanently once crossed, even
// after compacting history, which spammed repeated /compact runs.
let token_limit_reached = most_recent_usage_tokens
.map_or(false, |tokens| tokens >= limit);
// As long as compaction works well in getting us way below the token limit,
// we shouldn't worry about being in an infinite loop. However, guard against
// repeated compaction attempts within a single iteration.
if token_limit_reached && !did_proactive_compact_this_iteration {
did_proactive_compact_this_iteration = true;
sess
.notify_stream_error(
&sub_id,
"Token limit reached; running /compact and continuing…".to_string(),
)
.await;
// Choose between local and remote compact based on auth mode,
// matching upstream codex-rs behavior
if compact::should_use_remote_compact_task(&sess).await {
let _ = run_inline_remote_auto_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
Vec::new(),
)
.await;
} else {
let _ = compact::run_inline_auto_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
)
.await;
}
// Restart this loop with the newly compacted history so the
// next turn can see the trimmed conversation state.
continue;
}
// If there are responses, add them to pending input for the next iteration
if !responses.is_empty() {
if !is_review_mode {
@@ -5399,6 +5466,9 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
sess.add_pending_input(response.clone());
}
}
// Reset the proactive compact guard for the next iteration since we're
// about to process new tool calls and may need to compact again
did_proactive_compact_this_iteration = false;
}
if responses.is_empty() {
@@ -5621,11 +5691,20 @@ async fn run_turn(
.await;
let previous_input_snapshot = input.clone();
let compacted_history = compact::run_inline_auto_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
)
.await;
let compacted_history = if compact::should_use_remote_compact_task(sess).await {
run_inline_remote_auto_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
Vec::new(),
)
.await
} else {
compact::run_inline_auto_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
)
.await
};
// Reset any partial attempt state and rebuild the request payload using the
// newly compacted history plus the current user turn items.
@@ -6182,6 +6261,10 @@ async fn handle_response_item(
}
None
}
ResponseItem::CompactionSummary { .. } => {
// Keep compaction summaries in history; no user-visible event to emit.
None
}
ResponseItem::Reasoning {
id,
summary,
+52 -18
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use super::AgentTask;
use super::Session;
use super::compact_remote;
use super::TurnContext;
use super::get_last_assistant_message_from_turn;
use crate::Prompt;
@@ -27,6 +28,7 @@ use code_protocol::protocol::InputMessageKind;
use code_protocol::protocol::RolloutItem;
use base64::Engine;
use futures::prelude::*;
use code_app_server_protocol::AuthMode;
pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
pub const COMPACTION_CHECKPOINT_MESSAGE: &str = "History checkpoint: earlier conversation compacted.";
@@ -37,6 +39,29 @@ const COMPACT_TOOL_OUTPUT_MAX_BYTES: usize = 4 * 1024;
const COMPACT_IMAGE_URL_MAX_BYTES: usize = 512;
const MAX_COMPACTION_SNIPPETS: usize = 12;
/// Determine whether to use remote compaction (ChatGPT-based) or local compaction.
///
/// Upstream codex-rs checks if auth mode is ChatGPT and RemoteCompaction feature is enabled.
/// In code-rs, remote compaction infrastructure is not yet implemented, so this always
/// returns false (always use local compaction).
///
/// TODO: Once ChatGPT auth and remote compaction are implemented, update this to:
/// ```
/// session
/// .services
/// .auth_manager
/// .auth()
/// .is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
/// && session.enabled(Feature::RemoteCompaction).await
/// ```
pub(super) async fn should_use_remote_compact_task(session: &Session) -> bool {
session
.client
.get_auth_manager()
.and_then(|manager| manager.auth())
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompactionSnippet {
pub role: String,
@@ -157,14 +182,26 @@ pub(super) async fn run_compact_task(
) {
let start_event = sess.make_event(&sub_id, EventMsg::TaskStarted);
sess.send_event(start_event).await;
let _ = perform_compaction(
sess.clone(),
turn_context,
sub_id.clone(),
input,
true,
)
.await;
let compaction_result = if should_use_remote_compact_task(&sess).await {
compact_remote::run_remote_compact_task(
Arc::clone(&sess),
Arc::clone(&turn_context),
sub_id.clone(),
input,
)
.await
} else {
perform_compaction(
sess.clone(),
turn_context,
sub_id.clone(),
input,
true,
)
.await
};
let _ = compaction_result;
let event = sess.make_event(
&sub_id,
EventMsg::TaskComplete(TaskCompleteEvent {
@@ -253,13 +290,9 @@ pub(super) async fn perform_compaction(
let initial_context = sess.build_initial_context(turn_context.as_ref());
let new_history = build_compacted_history(initial_context, &snippets, &summary_text);
// Replace session history in-place
{
let mut state = sess.state.lock().unwrap();
// Replace entire history with the compacted one
state.history = crate::conversation_history::ConversationHistory::new();
state.history.record_items(new_history.iter());
}
// Replace session history in-place using the canonical helper so any future
// state bookkeeping stays centralized.
sess.replace_history(new_history);
send_compaction_checkpoint_warning(&sess, &sub_id).await;
@@ -356,6 +389,7 @@ async fn run_compact_task_inner_inline(
let mut state = sess.state.lock().unwrap();
state.history = crate::conversation_history::ConversationHistory::new();
state.history.record_items(new_history.iter());
state.token_usage_info = None;
}
send_compaction_checkpoint_warning(&sess, &sub_id).await;
@@ -407,7 +441,7 @@ fn truncate_for_compact(text: String, max_bytes: usize) -> String {
truncate_middle(&text, max_bytes).0
}
fn sanitize_items_for_compact(items: Vec<ResponseItem>) -> Vec<ResponseItem> {
pub(super) fn sanitize_items_for_compact(items: Vec<ResponseItem>) -> Vec<ResponseItem> {
items
.into_iter()
.filter_map(|item| match item {
@@ -510,7 +544,7 @@ fn compaction_checkpoint_warning_event() -> EventMsg {
})
}
async fn send_compaction_checkpoint_warning(sess: &Arc<Session>, sub_id: &str) {
pub(super) async fn send_compaction_checkpoint_warning(sess: &Arc<Session>, sub_id: &str) {
let event = sess.make_event(sub_id, compaction_checkpoint_warning_event());
sess.send_event(event).await;
}
@@ -570,7 +604,7 @@ async fn drain_to_completed(
}
// Helper copied from codex.rs (private there): convert core InputItem -> ResponseInputItem
fn response_input_from_core_items(items: Vec<InputItem>) -> ResponseInputItem {
pub(super) fn response_input_from_core_items(items: Vec<InputItem>) -> ResponseInputItem {
let mut content_items = Vec::new();
for item in items {
+118
View File
@@ -0,0 +1,118 @@
use std::sync::Arc;
use super::compact::{
response_input_from_core_items,
sanitize_items_for_compact,
send_compaction_checkpoint_warning,
};
use super::Session;
use super::TurnContext;
use crate::Prompt;
use crate::error::Result as CodexResult;
use crate::protocol::AgentMessageEvent;
use crate::protocol::ErrorEvent;
use crate::protocol::EventMsg;
use crate::protocol::InputItem;
use code_protocol::models::ResponseInputItem;
use code_protocol::models::ResponseItem;
use code_protocol::protocol::CompactedItem;
use code_protocol::protocol::RolloutItem;
pub(super) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
extra_input: Vec<InputItem>,
) -> Vec<ResponseItem> {
let sub_id = sess.next_internal_sub_id();
match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await {
Ok(history) => history,
Err(err) => {
let event = sess.make_event(
&sub_id,
EventMsg::Error(ErrorEvent {
message: format!("remote compact failed: {err}"),
}),
);
sess.send_event(event).await;
Vec::new()
}
}
}
pub(super) async fn run_remote_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
extra_input: Vec<InputItem>,
) -> CodexResult<()> {
match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await {
Ok(_history) => {
// Mirror local compaction behaviour: clear the running task when the
// compaction finished successfully so the UI can unblock.
sess.remove_task(&sub_id);
Ok(())
}
Err(err) => {
let event = sess.make_event(
&sub_id,
EventMsg::Error(ErrorEvent {
message: err.to_string(),
}),
);
sess.send_event(event).await;
Err(err)
}
}
}
async fn run_remote_compact_task_inner(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
sub_id: &str,
extra_input: Vec<InputItem>,
) -> CodexResult<Vec<ResponseItem>> {
let mut turn_items = sess.turn_input_with_history({
if extra_input.is_empty() {
Vec::new()
} else {
let response_input: ResponseInputItem = response_input_from_core_items(extra_input);
vec![ResponseItem::from(response_input)]
}
});
turn_items = sanitize_items_for_compact(turn_items);
let mut prompt = Prompt::default();
prompt.input = turn_items;
prompt.base_instructions_override = turn_context.base_instructions.clone();
prompt.include_additional_instructions = false;
prompt.log_tag = Some("codex/remote-compact".to_string());
let new_history = turn_context
.client
.compact_conversation_history(&prompt)
.await?;
sess.replace_history(new_history.clone());
{
let mut state = sess.state.lock().unwrap();
state.token_usage_info = None;
}
send_compaction_checkpoint_warning(sess, sub_id).await;
let rollout_item = RolloutItem::Compacted(CompactedItem {
message: "Conversation history compacted.".to_string(),
});
sess.persist_rollout_items(&[rollout_item]).await;
let event = sess.make_event(
sub_id,
EventMsg::AgentMessage(AgentMessageEvent {
message: "Compact task completed".to_string(),
}),
);
sess.send_event(event).await;
Ok(new_history)
}
+9 -1
View File
@@ -12,6 +12,7 @@ use crate::config_types::AllowedCommandMatchKind;
use crate::config_types::BrowserConfig;
use crate::config_types::CachedTerminalBackground;
use crate::config_types::ClientTools;
use crate::config_types::Notice;
use crate::config_types::History;
use crate::config_types::GithubConfig;
use crate::config_types::ValidationConfig;
@@ -69,7 +70,7 @@ use which::which;
const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex";
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-mini";
pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex";
pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex-max";
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
@@ -204,6 +205,9 @@ pub struct Config {
/// If unset the feature is disabled.
pub notify: Option<Vec<String>>,
/// Record of which one-time notices the user has acknowledged.
pub notices: Notice,
/// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals
/// and turn completions when not focused.
pub tui_notifications: Notifications,
@@ -1706,6 +1710,9 @@ pub struct ConfigToml {
#[serde(default)]
pub notify: Option<Vec<String>>,
/// Stored acknowledgement flags for in-product notices.
pub notice: Option<Notice>,
/// System instructions.
pub instructions: Option<String>,
@@ -2392,6 +2399,7 @@ impl Config {
.unwrap_or(false),
auto_upgrade_enabled: cfg.auto_upgrade_enabled.unwrap_or(false),
notify: cfg.notify,
notices: cfg.notice.unwrap_or_default(),
user_instructions,
base_instructions,
compact_prompt_override,
+11
View File
@@ -729,6 +729,15 @@ impl Default for Tui {
}
}
/// User acknowledgements for in-product notices (distinct from notifications).
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Notice {
pub hide_full_access_warning: Option<bool>,
pub hide_gpt5_1_migration_prompt: Option<bool>,
#[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")]
pub hide_gpt_5_1_codex_max_migration_prompt: Option<bool>,
}
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(transparent)]
pub struct AutoResolveAttemptLimit(u32);
@@ -1221,6 +1230,7 @@ pub enum ReasoningEffort {
#[default]
Medium,
High,
XHigh,
/// Deprecated: previously disabled reasoning. Kept for internal use only.
#[serde(skip)]
None,
@@ -1398,6 +1408,7 @@ impl From<code_protocol::config_types::ReasoningEffort> for ReasoningEffort {
code_protocol::config_types::ReasoningEffort::Low => ReasoningEffort::Low,
code_protocol::config_types::ReasoningEffort::Medium => ReasoningEffort::Medium,
code_protocol::config_types::ReasoningEffort::High => ReasoningEffort::High,
code_protocol::config_types::ReasoningEffort::XHigh => ReasoningEffort::XHigh,
}
}
}
+8
View File
@@ -32,6 +32,13 @@ impl ConversationHistory {
}
}
/// Replace the entire history with a new set of items.
/// Filtering is intentionally skipped here so compaction can preserve any
/// non-standard items (e.g., bridge summaries) exactly as computed.
pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) {
self.items = items;
}
}
/// Anything that is not a system message or "reasoning" message is considered
@@ -44,6 +51,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::CompactionSummary { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. } => true,
ResponseItem::Other => false,
+2
View File
@@ -107,6 +107,8 @@ pub(crate) fn map_response_item_to_event_messages(
events
}
ResponseItem::CompactionSummary { .. } => Vec::new(),
ResponseItem::Reasoning {
summary, content, ..
} => {
+9
View File
@@ -6,6 +6,7 @@ use crate::tool_apply_patch::ApplyPatchToolType;
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md");
const GPT_5_1_INSTRUCTIONS: &str = include_str!("../gpt_5_1_prompt.md");
const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md");
/// A model family is a group of models that share certain characteristics.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -100,6 +101,14 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true)
} else if slug.starts_with("gpt-3.5") {
model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true)
} else if slug.starts_with("gpt-5.1-codex-max") {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else if slug.starts_with("codex-")
|| slug.starts_with("gpt-5-codex")
|| slug.starts_with("gpt-5.1-codex")
+56 -29
View File
@@ -7,6 +7,7 @@
//! key. These override or extend the defaults at runtime.
use crate::CodexAuth;
use crate::error::CodexErr;
use code_app_server_protocol::AuthMode;
use serde::Deserialize;
use serde::Serialize;
@@ -193,40 +194,12 @@ impl ModelProviderInfo {
client: &'a reqwest::Client,
auth: &Option<CodexAuth>,
) -> crate::error::Result<reqwest::RequestBuilder> {
let effective_auth = match self.api_key() {
Ok(Some(key)) => Some(CodexAuth::from_api_key(&key)),
Ok(None) => auth.clone(),
Err(err) => {
if auth.is_some() {
auth.clone()
} else {
return Err(err);
}
}
};
let effective_auth = self.effective_auth(auth)?;
let url = self.get_full_url(&effective_auth);
let mut builder = client.post(&url);
// Always set an explicit Host header that matches the upstream target.
// Some forward proxies incorrectly reuse the inbound Host header
// (e.g. "127.0.0.1:5055") for TLS SNI when connecting to the
// upstream server, which causes handshake failures. By setting
// Host to the authority derived from the final URL here, we ensure
// the proxy sees the correct host and can forward/SNI appropriately.
if let Ok(parsed) = reqwest::Url::parse(&url) {
if let Some(host) = parsed.host_str() {
let authority = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
if let Ok(hv) = reqwest::header::HeaderValue::from_str(&authority) {
builder = builder.header(reqwest::header::HOST, hv);
}
}
}
if let Some(auth) = effective_auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
}
@@ -234,6 +207,48 @@ impl ModelProviderInfo {
Ok(self.apply_http_headers(builder))
}
pub async fn create_compact_request_builder<'a>(
&'a self,
client: &'a reqwest::Client,
auth: &Option<CodexAuth>,
) -> crate::error::Result<reqwest::RequestBuilder> {
if self.wire_api != WireApi::Responses {
return Err(CodexErr::UnsupportedOperation(
"Compaction endpoint requires Responses API providers".to_string(),
));
}
let effective_auth = self.effective_auth(auth)?;
let url = self.get_compact_url(&effective_auth).ok_or_else(|| {
CodexErr::UnsupportedOperation(
"Compaction endpoint requires Responses API providers".to_string(),
)
})?;
let mut builder = client.post(url);
if let Some(auth) = effective_auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
}
Ok(self.apply_http_headers(builder))
}
fn effective_auth(
&self,
auth: &Option<CodexAuth>,
) -> crate::error::Result<Option<CodexAuth>> {
match self.api_key() {
Ok(Some(key)) => Ok(Some(CodexAuth::from_api_key(&key))),
Ok(None) => Ok(auth.clone()),
Err(err) => {
if auth.is_some() {
Ok(auth.clone())
} else {
Err(err)
}
}
}
}
/// Returns the OpenRouter-specific configuration, if this provider declares one.
pub fn openrouter_config(&self) -> Option<&OpenRouterConfig> {
self.openrouter.as_ref()
@@ -276,6 +291,18 @@ impl ModelProviderInfo {
}
}
pub(crate) fn get_compact_url(&self, auth: &Option<CodexAuth>) -> Option<String> {
if self.wire_api != WireApi::Responses {
return None;
}
let full = self.get_full_url(auth);
if let Some((path, query)) = full.split_once('?') {
Some(format!("{path}/compact?{query}"))
} else {
Some(format!("{full}/compact"))
}
}
pub(crate) fn is_azure_responses_endpoint(&self) -> bool {
if self.wire_api != WireApi::Responses {
return false;
+11 -7
View File
@@ -23,9 +23,14 @@ impl ModelInfo {
Self {
context_window,
max_output_tokens,
auto_compact_token_limit: None,
auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)),
}
}
const fn default_auto_compact_limit(context_window: u64) -> i64 {
// 90% of context window, matching upstream codex-rs behavior
((context_window * 9) / 10) as i64
}
}
pub fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
@@ -62,12 +67,11 @@ pub fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
// https://platform.openai.com/docs/models/gpt-3.5-turbo
"gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)),
_ if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") => {
Some(ModelInfo {
context_window: 272_000,
max_output_tokens: 128_000,
auto_compact_token_limit: Some(350_000),
})
_ if slug.starts_with("gpt-5-codex")
|| slug.starts_with("gpt-5.1-codex")
|| slug.starts_with("gpt-5.1-codex-max") =>
{
Some(ModelInfo::new(272_000, 128_000))
}
_ if slug.starts_with("gpt-5.1") => Some(ModelInfo::new(272_000, 128_000)),
+1
View File
@@ -28,6 +28,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::CompactionSummary { .. }
| ResponseItem::WebSearchCall { .. } => true,
ResponseItem::Other => false,
}
+7 -1
View File
@@ -4,6 +4,7 @@ use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::compact;
use crate::codex::compact_remote;
use crate::protocol::InputItem;
use crate::state::TaskKind;
@@ -26,7 +27,12 @@ impl SessionTask for CompactTask {
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await;
let session_arc = session.clone_session();
if compact::should_use_remote_compact_task(&session_arc).await {
compact_remote::run_remote_compact_task(session_arc, ctx, sub_id, input).await;
} else {
compact::run_compact_task(session_arc, ctx, sub_id, input).await;
}
None
}
}
+3 -6
View File
@@ -1501,7 +1501,7 @@ fn session_models_from_config(config: &Config) -> Option<acp::SessionModelState>
};
available_models.push(acp::ModelInfo {
model_id: id.clone(),
name: preset.label.to_string(),
name: preset.display_name.to_string(),
description,
meta: None,
});
@@ -1550,10 +1550,7 @@ fn session_models_from_config(config: &Config) -> Option<acp::SessionModelState>
}
fn preset_effort(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
preset.default_reasoning_effort.into()
}
fn resolve_model_selection(model_id: &acp::ModelId, config: &Config) -> Option<ModelSelection> {
@@ -1562,7 +1559,7 @@ fn resolve_model_selection(model_id: &acp::ModelId, config: &Config) -> Option<M
for preset in builtin_model_presets(None).iter() {
if preset.id.eq_ignore_ascii_case(&requested)
|| preset.label.eq_ignore_ascii_case(&requested)
|| preset.display_name.eq_ignore_ascii_case(&requested)
|| preset.model.eq_ignore_ascii_case(&requested)
{
return Some(ModelSelection {
+1
View File
@@ -20,6 +20,7 @@ pub enum ReasoningEffort {
#[default]
Medium,
High,
XHigh,
}
/// A summary of the reasoning performed by the model. This can be useful for
+5
View File
@@ -56,6 +56,11 @@ pub enum ResponseItem {
content: Option<Vec<ReasoningItemContent>>,
encrypted_content: Option<String>,
},
/// Produced by the /responses/compact endpoint. Carries an encrypted summary
/// of the prior conversation that must be forwarded verbatim on the next turn.
CompactionSummary {
encrypted_content: String,
},
LocalShellCall {
/// Set when using the chat completions API.
#[serde(skip_serializing)]
@@ -1,12 +1,11 @@
use super::BottomPane;
use super::bottom_pane_view::BottomPaneView;
use super::settings_panel::{render_panel, PanelFrameStyle};
use super::BottomPane;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use code_common::model_presets::ModelPreset;
use code_core::config_types::ReasoningEffort;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::Widget;
@@ -15,7 +14,43 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use std::cmp::Ordering;
use super::settings_panel::{render_panel, PanelFrameStyle};
/// Flattened preset entry combining a model with a specific reasoning effort.
#[derive(Clone, Debug)]
struct FlatPreset {
model: String,
effort: ReasoningEffort,
label: String,
description: String,
}
impl FlatPreset {
fn from_model_preset(preset: &ModelPreset) -> Vec<Self> {
preset
.supported_reasoning_efforts
.iter()
.map(|effort_preset| {
let effort_label = Self::effort_label(effort_preset.effort.into());
FlatPreset {
model: preset.model.to_string(),
effort: effort_preset.effort.into(),
label: format!("{} {}", preset.display_name, effort_label.to_lowercase()),
description: effort_preset.description.to_string(),
}
})
.collect()
}
fn effort_label(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::XHigh => "XHigh",
ReasoningEffort::High => "High",
ReasoningEffort::Medium => "Medium",
ReasoningEffort::Low => "Low",
ReasoningEffort::Minimal => "Minimal",
ReasoningEffort::None => "None",
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum ModelSelectionTarget {
@@ -47,7 +82,7 @@ impl ModelSelectionTarget {
}
pub(crate) struct ModelSelectionView {
presets: Vec<ModelPreset>,
flat_presets: Vec<FlatPreset>,
selected_index: usize,
current_model: String,
current_effort: ReasoningEffort,
@@ -64,9 +99,14 @@ impl ModelSelectionView {
target: ModelSelectionTarget,
app_event_tx: AppEventSender,
) -> Self {
let initial_index = Self::initial_selection(&presets, &current_model, current_effort);
let flat_presets: Vec<FlatPreset> = presets
.iter()
.flat_map(FlatPreset::from_model_preset)
.collect();
let initial_index = Self::initial_selection(&flat_presets, &current_model, current_effort);
Self {
presets,
flat_presets,
selected_index: initial_index,
current_model,
current_effort,
@@ -77,19 +117,17 @@ impl ModelSelectionView {
}
fn initial_selection(
presets: &[ModelPreset],
flat_presets: &[FlatPreset],
current_model: &str,
current_effort: ReasoningEffort,
) -> usize {
// Prefer an exact match on model + effort, fall back to first model match, then first entry.
if let Some((idx, _)) = presets.iter().enumerate().find(|(_, preset)| {
preset.model.eq_ignore_ascii_case(current_model)
&& Self::preset_effort(preset) == current_effort
if let Some((idx, _)) = flat_presets.iter().enumerate().find(|(_, preset)| {
preset.model.eq_ignore_ascii_case(current_model) && preset.effort == current_effort
}) {
return idx;
}
if let Some((idx, _)) = presets
if let Some((idx, _)) = flat_presets
.iter()
.enumerate()
.find(|(_, preset)| preset.model.eq_ignore_ascii_case(current_model))
@@ -100,13 +138,6 @@ impl ModelSelectionView {
0
}
fn preset_effort(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
}
fn format_model_header(model: &str) -> String {
let mut parts = Vec::new();
for (idx, part) in model.split('-').enumerate() {
@@ -138,7 +169,7 @@ impl ModelSelectionView {
}
fn move_selection_up(&mut self) {
if self.presets.is_empty() {
if self.flat_presets.is_empty() {
return;
}
let sorted = self.sorted_indices();
@@ -159,7 +190,7 @@ impl ModelSelectionView {
}
fn move_selection_down(&mut self) {
if self.presets.is_empty() {
if self.flat_presets.is_empty() {
return;
}
let sorted = self.sorted_indices();
@@ -176,19 +207,18 @@ impl ModelSelectionView {
}
fn confirm_selection(&mut self) {
if let Some(preset) = self.presets.get(self.selected_index) {
let effort = Self::preset_effort(preset);
if let Some(flat_preset) = self.flat_presets.get(self.selected_index) {
match self.target {
ModelSelectionTarget::Session => {
let _ = self.app_event_tx.send(AppEvent::UpdateModelSelection {
model: preset.model.to_string(),
effort: Some(effort),
model: flat_preset.model.clone(),
effort: Some(flat_preset.effort),
});
}
ModelSelectionTarget::Review => {
let _ = self.app_event_tx.send(AppEvent::UpdateReviewModelSelection {
model: preset.model.to_string(),
effort,
model: flat_preset.model.clone(),
effort: flat_preset.effort,
});
}
}
@@ -197,45 +227,40 @@ impl ModelSelectionView {
}
fn content_line_count(&self) -> u16 {
// Current model, reasoning effort, and initial spacer.
let mut lines: u16 = 3;
let mut previous_model: Option<&str> = None;
for idx in self.sorted_indices() {
let preset = &self.presets[idx];
let flat_preset = &self.flat_presets[idx];
let is_new_model = previous_model
.map(|prev| !prev.eq_ignore_ascii_case(&preset.model))
.map(|prev| !prev.eq_ignore_ascii_case(&flat_preset.model))
.unwrap_or(true);
if is_new_model {
if previous_model.is_some() {
// Spacer between model groups.
lines = lines.saturating_add(1);
}
// Header when entering a new model group.
lines = lines.saturating_add(1);
if Self::model_description(preset.model).is_some() {
if Self::model_description(&flat_preset.model).is_some() {
lines = lines.saturating_add(1);
}
previous_model = Some(preset.model);
previous_model = Some(&flat_preset.model);
}
// The preset entry row.
lines = lines.saturating_add(1);
}
// Spacer before footer plus footer hint row.
lines.saturating_add(2)
}
fn sorted_indices(&self) -> Vec<usize> {
let mut indices: Vec<usize> = (0..self.presets.len()).collect();
indices.sort_by(|&a, &b| Self::compare_presets(&self.presets[a], &self.presets[b]));
let mut indices: Vec<usize> = (0..self.flat_presets.len()).collect();
indices.sort_by(|&a, &b| Self::compare_presets(&self.flat_presets[a], &self.flat_presets[b]));
indices
}
fn compare_presets(a: &ModelPreset, b: &ModelPreset) -> Ordering {
let model_rank = Self::model_rank(a.model).cmp(&Self::model_rank(b.model));
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;
}
@@ -248,32 +273,35 @@ impl ModelSelectionView {
return model_name_rank;
}
let effort_rank = Self::effort_rank(Self::preset_effort(a))
.cmp(&Self::effort_rank(Self::preset_effort(b)));
let effort_rank = Self::effort_rank(a.effort).cmp(&Self::effort_rank(b.effort));
if effort_rank != Ordering::Equal {
return effort_rank;
}
a.label.cmp(b.label)
a.label.cmp(&b.label)
}
fn model_rank(model: &str) -> u8 {
if model.eq_ignore_ascii_case("gpt-5.1-codex") {
if model.eq_ignore_ascii_case("gpt-5.1-codex-max") {
0
} else if model.eq_ignore_ascii_case("gpt-5.1-codex-mini") {
} else if model.eq_ignore_ascii_case("gpt-5.1-codex") {
1
} else if model.eq_ignore_ascii_case("gpt-5.1") {
} else if model.eq_ignore_ascii_case("gpt-5.1-codex-mini") {
2
} else {
} else if model.eq_ignore_ascii_case("gpt-5.1") {
3
} else {
4
}
}
fn model_description(model: &str) -> Option<&'static str> {
if model.eq_ignore_ascii_case("gpt-5.1-codex") {
Some("Optimized for coding.")
if model.eq_ignore_ascii_case("gpt-5.1-codex-max") {
Some("Latest Codex-optimized flagship for deep and fast reasoning.")
} else if model.eq_ignore_ascii_case("gpt-5.1-codex") {
Some("Optimized for Code.")
} else if model.eq_ignore_ascii_case("gpt-5.1-codex-mini") {
Some("Optimized for coding. Cheaper, faster, but less capable.")
Some("Optimized for Code. Cheaper, faster, but less capable.")
} else if model.eq_ignore_ascii_case("gpt-5.1") {
Some("Broad world knowledge with strong general reasoning.")
} else {
@@ -283,16 +311,18 @@ impl ModelSelectionView {
fn effort_rank(effort: ReasoningEffort) -> u8 {
match effort {
ReasoningEffort::High => 0,
ReasoningEffort::Medium => 1,
ReasoningEffort::Low => 2,
ReasoningEffort::Minimal => 3,
ReasoningEffort::None => 4,
ReasoningEffort::XHigh => 0,
ReasoningEffort::High => 1,
ReasoningEffort::Medium => 2,
ReasoningEffort::Low => 3,
ReasoningEffort::Minimal => 4,
ReasoningEffort::None => 5,
}
}
fn effort_label(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::XHigh => "XHigh",
ReasoningEffort::High => "High",
ReasoningEffort::Medium => "Medium",
ReasoningEffort::Low => "Low",
@@ -300,55 +330,24 @@ impl ModelSelectionView {
ReasoningEffort::None => "None",
}
}
fn effort_description(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::Minimal => {
"Minimal reasoning. When speed is more important than accuracy. (fastest)"
}
ReasoningEffort::Low => "Basic reasoning. Works quickly in simple code bases. (fast)",
ReasoningEffort::Medium => "Balanced reasoning. Ideal for most tasks. (default)",
ReasoningEffort::High => {
"Deep reasoning. Useful when solving difficult problems. (slower)"
}
ReasoningEffort::None => "Reasoning disabled",
}
}
}
impl ModelSelectionView {
pub(crate) fn handle_key_event_direct(&mut self, key_event: KeyEvent) -> bool {
match key_event {
KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::NONE,
..
} => {
KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => {
self.move_selection_up();
true
}
KeyEvent {
code: KeyCode::Down,
modifiers: KeyModifiers::NONE,
..
}
=> {
KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => {
self.move_selection_down();
true
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
self.confirm_selection();
true
}
KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
..
} => {
KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, .. } => {
self.is_complete = true;
true
}
@@ -389,37 +388,34 @@ impl ModelSelectionView {
lines.push(Line::from(""));
let mut previous_model: Option<&str> = None;
let sorted_indices = self.sorted_indices();
for preset_index in sorted_indices {
let preset = &self.presets[preset_index];
for preset_index in self.sorted_indices() {
let flat_preset = &self.flat_presets[preset_index];
if previous_model
.map(|m| !m.eq_ignore_ascii_case(&preset.model))
.map(|m| !m.eq_ignore_ascii_case(&flat_preset.model))
.unwrap_or(true)
{
if previous_model.is_some() {
lines.push(Line::from(""));
}
lines.push(Line::from(vec![Span::styled(
Self::format_model_header(&preset.model),
Self::format_model_header(&flat_preset.model),
Style::default()
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
)]));
if let Some(desc) = Self::model_description(&preset.model) {
if let Some(desc) = Self::model_description(&flat_preset.model) {
lines.push(Line::from(vec![Span::styled(
desc,
Style::default().fg(crate::colors::text_dim()),
)]));
}
previous_model = Some(preset.model);
previous_model = Some(&flat_preset.model);
}
let is_selected = preset_index == self.selected_index;
let preset_effort = Self::preset_effort(preset);
let is_current = preset.model.eq_ignore_ascii_case(&self.current_model)
&& preset_effort == self.current_effort;
let label = Self::effort_label(preset_effort);
let is_current = flat_preset.model.eq_ignore_ascii_case(&self.current_model)
&& flat_preset.effort == self.current_effort;
let label = Self::effort_label(flat_preset.effort);
let mut row_text = label.to_string();
if is_current {
row_text.push_str(" (current)");
@@ -456,13 +452,11 @@ impl ModelSelectionView {
.add_modifier(Modifier::BOLD);
}
let description = Self::effort_description(preset_effort);
lines.push(Line::from(vec![
Span::styled(" ", indent_style),
Span::styled(row_text, label_style),
Span::styled(" - ", divider_style),
Span::styled(description, description_style),
Span::styled(&flat_preset.description, description_style),
]));
}
@@ -496,7 +490,6 @@ impl ModelSelectionView {
pub(crate) fn render_without_frame(&self, area: Rect, buf: &mut Buffer) {
self.render_panel_body(area, buf);
}
}
impl<'a> BottomPaneView<'a> for ModelSelectionView {
@@ -509,7 +502,6 @@ impl<'a> BottomPaneView<'a> for ModelSelectionView {
}
fn desired_height(&self, _width: u16) -> u16 {
// Account for content rows plus bordered block padding.
let content_lines = self.content_line_count();
let total = content_lines.saturating_add(2);
total.max(9)
@@ -139,6 +139,7 @@ impl ReviewSettingsView {
fn reasoning_label(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::XHigh => "XHigh",
ReasoningEffort::High => "High",
ReasoningEffort::Medium => "Medium",
ReasoningEffort::Low => "Low",
+14 -19
View File
@@ -18632,10 +18632,7 @@ Have we met every part of this goal and is there no further work to do?"#
}
fn preset_effort_for_model(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
preset.default_reasoning_effort.into()
}
fn find_model_preset(&self, input: &str, presets: &[ModelPreset]) -> Option<ModelPreset> {
@@ -18650,49 +18647,45 @@ Have we met every part of this goal and is there no further work to do?"#
.collect();
let mut fallback_medium: Option<ModelPreset> = None;
let mut fallback_none: Option<ModelPreset> = None;
let mut fallback_first: Option<ModelPreset> = None;
for &preset in presets.iter() {
let preset_effort = Self::preset_effort_for_model(&preset);
for preset in presets.iter() {
let preset_effort = Self::preset_effort_for_model(preset);
let id_lower = preset.id.to_ascii_lowercase();
if Self::candidate_matches(&input_lower, &collapsed_input, &id_lower) {
return Some(preset);
return Some(preset.clone());
}
let label_lower = preset.label.to_ascii_lowercase();
if Self::candidate_matches(&input_lower, &collapsed_input, &label_lower) {
return Some(preset);
let display_name_lower = preset.display_name.to_ascii_lowercase();
if Self::candidate_matches(&input_lower, &collapsed_input, &display_name_lower) {
return Some(preset.clone());
}
let effort_lower = preset_effort.to_string().to_ascii_lowercase();
let model_lower = preset.model.to_ascii_lowercase();
let spaced = format!("{model_lower} {effort_lower}");
if Self::candidate_matches(&input_lower, &collapsed_input, &spaced) {
return Some(preset);
return Some(preset.clone());
}
let dashed = format!("{model_lower}-{effort_lower}");
if Self::candidate_matches(&input_lower, &collapsed_input, &dashed) {
return Some(preset);
return Some(preset.clone());
}
if model_lower == input_lower
|| Self::candidate_matches(&input_lower, &collapsed_input, &model_lower)
{
if fallback_medium.is_none() && preset_effort == ReasoningEffort::Medium {
fallback_medium = Some(preset);
}
if fallback_none.is_none() && preset.effort.is_none() {
fallback_none = Some(preset);
fallback_medium = Some(preset.clone());
}
if fallback_first.is_none() {
fallback_first = Some(preset);
fallback_first = Some(preset.clone());
}
}
}
fallback_medium.or(fallback_none).or(fallback_first)
fallback_medium.or(fallback_first)
}
fn candidate_matches(input: &str, collapsed_input: &str, candidate: &str) -> bool {
@@ -18890,6 +18883,7 @@ Have we met every part of this goal and is there no further work to do?"#
"minimal" | "min" => ReasoningEffort::Minimal,
"low" => ReasoningEffort::Low,
"medium" | "med" => ReasoningEffort::Medium,
"xhigh" | "extra-high" | "extra_high" => ReasoningEffort::XHigh,
"high" => ReasoningEffort::High,
// Backwards compatibility: map legacy values to minimal.
"none" | "off" => ReasoningEffort::Minimal,
@@ -19764,6 +19758,7 @@ Have we met every part of this goal and is there no further work to do?"#
ReasoningEffort::Low => "Low",
ReasoningEffort::Medium => "Medium",
ReasoningEffort::High => "High",
ReasoningEffort::XHigh => "XHigh",
}
}
+215 -2
View File
@@ -4,6 +4,15 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
#![deny(clippy::disallowed_methods)]
use app::App;
use code_common::model_presets::{
all_model_presets,
ModelPreset,
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
};
use code_core::config_edit::{self, CONFIG_KEY_EFFORT, CONFIG_KEY_MODEL};
use code_core::config_types::Notice;
use code_core::config_types::ReasoningEffort;
use code_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use code_core::config::set_cached_terminal_background;
use code_core::config::Config;
@@ -21,10 +30,12 @@ use code_core::config_types::ThemeName;
use regex_lite::Regex;
use code_login::AuthMode;
use code_login::CodexAuth;
use model_migration::{migration_copy_for_key, run_model_migration_prompt, ModelMigrationOutcome};
use code_ollama::DEFAULT_OSS_MODEL;
use code_protocol::config_types::SandboxMode;
use std::fs::OpenOptions;
use std::path::PathBuf;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Once;
use std::sync::OnceLock;
use tracing_appender::non_blocking;
@@ -68,6 +79,7 @@ mod syntax_highlight;
pub mod onboarding;
pub mod public_widgets;
mod render;
mod model_migration;
// mod scroll_view; // Orphaned after trait-based HistoryCell migration
mod session_log;
mod shimmer;
@@ -298,6 +310,13 @@ pub struct ExitSummary {
pub session_id: Option<Uuid>,
}
fn empty_exit_summary() -> ExitSummary {
ExitSummary {
token_usage: code_core::protocol::TokenUsage::default(),
session_id: None,
}
}
pub fn resume_command_name() -> &'static str {
static COMMAND: OnceLock<&'static str> = OnceLock::new();
COMMAND.get_or_init(|| {
@@ -433,7 +452,7 @@ pub async fn run_main(
// Load configuration and support CLI overrides.
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides.clone()) {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
@@ -442,6 +461,84 @@ pub async fn run_main(
}
};
let cli_model_override = cli.model.is_some()
|| cli_kv_overrides
.iter()
.any(|(path, _)| path == "model" || path.ends_with(".model"));
if !cli_model_override && !cli.oss {
let auth_mode = if config.using_chatgpt_auth {
AuthMode::ChatGPT
} else {
AuthMode::ApiKey
};
if let Some(plan) = determine_migration_plan(&config, auth_mode) {
if matches!(auth_mode, AuthMode::ChatGPT) {
if let Err(err) = persist_migration_acceptance(
&code_home,
cli.config_profile.as_deref(),
plan,
)
.await
{
tracing::warn!("failed to persist migration acceptance: {err}");
} else {
match Config::load_with_cli_overrides(
cli_kv_overrides.clone(),
overrides.clone(),
) {
Ok(updated) => config = updated,
Err(err) => {
eprintln!("Error reloading configuration: {err}");
std::process::exit(1);
}
}
}
} else {
let copy = migration_copy_for_key(plan.hide_key);
match run_model_migration_prompt(&copy)? {
ModelMigrationOutcome::Accepted => {
if let Err(err) = persist_migration_acceptance(
&code_home,
cli.config_profile.as_deref(),
plan,
)
.await
{
tracing::warn!("failed to persist migration acceptance: {err}");
} else {
match Config::load_with_cli_overrides(
cli_kv_overrides.clone(),
overrides.clone(),
) {
Ok(updated) => config = updated,
Err(err) => {
eprintln!("Error reloading configuration: {err}");
std::process::exit(1);
}
}
}
}
ModelMigrationOutcome::Rejected => {
let hide_key = plan.hide_key;
if let Err(err) = persist_notice_hide(
&code_home,
cli.config_profile.as_deref(),
hide_key,
)
.await
{
tracing::warn!("failed to persist migration opt-out: {err}");
}
set_notice_flag(&mut config.notices, hide_key);
}
ModelMigrationOutcome::Exit => {
return Ok(empty_exit_summary());
}
}
}
}
}
let startup_footer_notice = None;
// we load config.toml here to determine project state.
@@ -843,6 +940,122 @@ fn maybe_apply_terminal_theme_detection(config: &mut Config, theme_configured_ex
}
}
#[derive(Clone, Copy)]
struct MigrationPlan {
target: &'static ModelPreset,
hide_key: &'static str,
new_effort: Option<ReasoningEffort>,
}
fn determine_migration_plan(config: &Config, auth_mode: AuthMode) -> Option<MigrationPlan> {
let current_slug = config.model.to_ascii_lowercase();
let presets = all_model_presets();
let current = presets
.iter()
.find(|preset| preset.id.eq_ignore_ascii_case(&current_slug))?;
let upgrade = current.upgrade.as_ref()?;
if notice_hidden(&config.notices, upgrade.migration_config_key) {
return None;
}
let target = presets
.iter()
.find(|preset| preset.id.eq_ignore_ascii_case(upgrade.id))?;
if !auth_allows_target(auth_mode, target) {
return None;
}
let new_effort = None;
Some(MigrationPlan {
target,
hide_key: upgrade.migration_config_key,
new_effort,
})
}
const NOTICE_TABLE: &str = "notice";
async fn persist_migration_acceptance(
code_home: &Path,
profile: Option<&str>,
plan: MigrationPlan,
) -> io::Result<()> {
let mut pending: Vec<(Vec<&str>, String)> = Vec::new();
pending.push((vec![CONFIG_KEY_MODEL], plan.target.model.to_string()));
if let Some(effort) = plan.new_effort {
pending.push((
vec![CONFIG_KEY_EFFORT],
reasoning_effort_to_str(effort).to_string(),
));
}
pending.push((vec![NOTICE_TABLE, plan.hide_key], "true".to_string()));
let overrides: Vec<(&[&str], &str)> = pending
.iter()
.map(|(path, value)| (path.as_slice(), value.as_str()))
.collect();
config_edit::persist_overrides(code_home, profile, &overrides)
.await
.map_err(|err| io::Error::other(err.to_string()))
}
async fn persist_notice_hide(
code_home: &Path,
profile: Option<&str>,
hide_key: &'static str,
) -> io::Result<()> {
let notice_path = [NOTICE_TABLE, hide_key];
let overrides = [(&notice_path[..], "true")];
config_edit::persist_overrides(code_home, profile, &overrides)
.await
.map_err(|err| io::Error::other(err.to_string()))
}
fn set_notice_flag(notices: &mut Notice, key: &str) {
match key {
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
notices.hide_gpt5_1_migration_prompt = Some(true);
}
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => {
notices.hide_gpt_5_1_codex_max_migration_prompt = Some(true);
}
_ => {}
}
}
fn notice_hidden(notices: &Notice, key: &str) -> bool {
match key {
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => {
notices.hide_gpt_5_1_codex_max_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn auth_allows_target(auth_mode: AuthMode, target: &ModelPreset) -> bool {
if matches!(auth_mode, AuthMode::ApiKey)
&& target.id.eq_ignore_ascii_case("gpt-5.1-codex-max")
{
return false;
}
true
}
fn reasoning_effort_to_str(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::Minimal => "minimal",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
ReasoningEffort::XHigh => "xhigh",
ReasoningEffort::None => "none",
}
}
fn apply_detected_theme(theme: &mut ThemeConfig, is_dark: bool) {
if is_dark {
theme.name = ThemeName::DarkCarbonNight;
+138
View File
@@ -0,0 +1,138 @@
use std::io::{self, Write};
use crossterm::cursor::MoveTo;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use crossterm::ExecutableCommand;
pub(crate) enum ModelMigrationOutcome {
Accepted,
Rejected,
Exit,
}
pub(crate) struct ModelMigrationCopy {
pub heading: &'static str,
pub content: &'static [&'static str],
pub can_opt_out: bool,
}
pub(crate) fn migration_copy_for_key(key: &str) -> ModelMigrationCopy {
match key {
code_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => ModelMigrationCopy {
heading: "Introducing our gpt-5.1 models",
content: &[
"We've upgraded Codex to gpt-5.1, gpt-5.1-codex, and gpt-5.1-codex-mini.",
"Legacy gpt-5 models continue to work via -m or config.toml overrides.",
"Learn more: www.openai.com/index/gpt-5-1",
"Press Enter to continue.",
],
can_opt_out: false,
},
_ => ModelMigrationCopy {
heading: "Codex just got an upgrade: meet gpt-5.1-codex-max",
content: &[
"Our flagship agentic coding model is smarter, faster, and tuned for long sessions.",
"Everyone signed in with ChatGPT gets it automatically.",
"Learn more: www.openai.com/index/gpt-5-1-codex-max",
"Choose how you'd like Codex to proceed.",
],
can_opt_out: true,
},
}
}
pub(crate) fn run_model_migration_prompt(copy: &ModelMigrationCopy) -> io::Result<ModelMigrationOutcome> {
struct RawModeGuard;
impl RawModeGuard {
fn new() -> io::Result<Self> {
enable_raw_mode()?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
let _guard = RawModeGuard::new()?;
let mut stdout = io::stdout();
let mut highlighted = 0usize;
render_prompt(&mut stdout, copy, highlighted)?;
loop {
let event = event::read()?;
if let Event::Key(KeyEvent { code, modifiers, kind, .. }) = event {
if matches!(kind, KeyEventKind::Release) {
continue;
}
if modifiers.contains(KeyModifiers::CONTROL)
&& matches!(code, KeyCode::Char('c') | KeyCode::Char('d'))
{
return Ok(ModelMigrationOutcome::Exit);
}
if !copy.can_opt_out {
match code {
KeyCode::Enter | KeyCode::Esc => {
return Ok(ModelMigrationOutcome::Accepted);
}
_ => {}
}
continue;
}
match code {
KeyCode::Up | KeyCode::Char('k') => {
highlighted = 0;
render_prompt(&mut stdout, copy, highlighted)?;
}
KeyCode::Down | KeyCode::Char('j') => {
highlighted = 1;
render_prompt(&mut stdout, copy, highlighted)?;
}
KeyCode::Char('1') => return Ok(ModelMigrationOutcome::Accepted),
KeyCode::Char('2') => return Ok(ModelMigrationOutcome::Rejected),
KeyCode::Enter => {
return if highlighted == 0 {
Ok(ModelMigrationOutcome::Accepted)
} else {
Ok(ModelMigrationOutcome::Rejected)
};
}
KeyCode::Esc => return Ok(ModelMigrationOutcome::Rejected),
KeyCode::Char('q') => return Ok(ModelMigrationOutcome::Exit),
_ => {}
}
}
}
}
fn render_prompt(stdout: &mut io::Stdout, copy: &ModelMigrationCopy, highlighted: usize) -> io::Result<()> {
stdout.execute(Clear(ClearType::All))?;
stdout.execute(MoveTo(0, 0))?;
writeln!(stdout, "{}", copy.heading)?;
writeln!(stdout)?;
for line in copy.content {
writeln!(stdout, "{}", line)?;
}
if copy.can_opt_out {
writeln!(stdout)?;
for (idx, label) in ["Try new model (recommended)", "Use existing model"].iter().enumerate() {
if idx == highlighted {
writeln!(stdout, "> {label}")?;
} else {
writeln!(stdout, " {label}")?;
}
}
writeln!(stdout)?;
writeln!(stdout, "Use ↑/↓ to move, Enter to confirm, Esc to keep current model.")?;
}
stdout.flush()
}
+5
View File
@@ -134,6 +134,11 @@ set = { EDITOR = "vim", PAGER = "less" }
persistence = "save-all"
max-bytes = 10485760 # 10MB
# Notice acknowledgement flags
[notice]
hide_gpt5_1_migration_prompt = false
hide_gpt-5.1-codex-max_migration_prompt = false
# MCP Server Configuration
# Example MCP server configurations
[mcp-servers.example-server]
+13 -1
View File
@@ -22,7 +22,7 @@ Both the `--config` flag and the `config.toml` file support the following option
The model that Codex should use.
```toml
model = "o3" # overrides the default of "gpt-5.1"
model = "o3" # overrides the default of "gpt-5.1-codex-max"
```
## model_providers
@@ -198,6 +198,18 @@ args = ["-y"]
env = { GEMINI_API_KEY = "..." }
```
## notice
Codex stores acknowledgement flags for one-time upgrade prompts inside a `[notice]`
table. These booleans allow you to suppress specific dialogs globally. When set
to `true`, Codex will no longer prompt about that migration.
```toml
[notice]
hide_gpt5_1_migration_prompt = true
hide_gpt-5.1-codex-max_migration_prompt = true
```
When `enabled = true`, the agent is surfaced in the TUI picker and any sub-agent commands that reference it. Setting `read-only = true` forces the agent to request approval before modifying files even if the primary session permits writes.
## profiles
@@ -24,27 +24,46 @@ CRITICAL_DIR="${OUTPUT_DIR}/critical-changes"
mkdir -p "$CRITICAL_DIR"
# Critical files and patterns to watch
declare -A CRITICAL_PATTERNS=(
# Prompts and tool definitions
["prompts"]="prompts/.*\.md"
["openai_tools"]="codex-rs/core/src/openai_tools\.rs|code-rs/core/src/openai_tools\.rs"
["agent_tool"]="codex-rs/core/src/agent_tool\.rs|code-rs/core/src/agent_tool\.rs"
# Protocol and API surface
["protocol"]="codex-rs/core/src/protocol\.rs|code-rs/core/src/protocol\.rs"
["app_server_protocol"]="codex-rs/app-server-protocol/src/.*|code-rs/app-server-protocol/src/.*"
["mcp_types"]="codex-rs/mcp-types/src/.*|code-rs/mcp-types/src/.*"
# Executor and core logic
["codex_main"]="codex-rs/core/src/codex\.rs|code-rs/core/src/codex\.rs"
["exec"]="codex-rs/exec/src/.*|code-rs/exec/src/.*"
["apply_patch"]="codex-rs/apply-patch/src/.*|code-rs/apply-patch/src/.*"
["acp"]="codex-rs/core/src/acp\.rs|code-rs/core/src/acp\.rs"
# Config and behavior toggles
["config"]="codex-rs/core/src/config.*\.rs|code-rs/core/src/config.*\.rs"
CRITICAL_PATTERN_KEYS=(
"prompts"
"openai_tools"
"agent_tool"
"protocol"
"app_server_protocol"
"mcp_types"
"codex_main"
"exec"
"apply_patch"
"acp"
"config"
)
CRITICAL_PATTERN_REGEXES=(
'prompts/.*\.md'
'codex-rs/core/src/openai_tools\.rs|code-rs/core/src/openai_tools\.rs'
'codex-rs/core/src/agent_tool\.rs|code-rs/core/src/agent_tool\.rs'
'codex-rs/core/src/protocol\.rs|code-rs/core/src/protocol\.rs'
'codex-rs/app-server-protocol/src/.*|code-rs/app-server-protocol/src/.*'
'codex-rs/mcp-types/src/.*|code-rs/mcp-types/src/.*'
'codex-rs/core/src/codex\.rs|code-rs/core/src/codex\.rs'
'codex-rs/exec/src/.*|code-rs/exec/src/.*'
'codex-rs/apply-patch/src/.*|code-rs/apply-patch/src/.*'
'codex-rs/core/src/acp\.rs|code-rs/core/src/acp\.rs'
'codex-rs/core/src/config.*\.rs|code-rs/core/src/config.*\.rs'
)
pattern_for_category() {
local key="$1"
local idx
for ((idx = 0; idx < ${#CRITICAL_PATTERN_KEYS[@]}; idx++)); do
if [[ "${CRITICAL_PATTERN_KEYS[$idx]}" == "$key" ]]; then
printf '%s' "${CRITICAL_PATTERN_REGEXES[$idx]}"
return 0
fi
done
return 1
}
# Color codes for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
@@ -76,8 +95,9 @@ Generated: $(date -u +%Y-%m-%d\ %H:%M:%S\ UTC)
HEADER
# Check for each critical pattern
for category in "${!CRITICAL_PATTERNS[@]}"; do
local pattern="${CRITICAL_PATTERNS[$category]}"
for category in "${CRITICAL_PATTERN_KEYS[@]}"; do
local pattern
pattern=$(pattern_for_category "$category") || continue
# Extract relevant sections from diff
if grep -E "^\+\+\+ |^--- " "$diff_file" | grep -E "$pattern" > /dev/null 2>&1; then