feat(model-routing): surface reroute warnings and status models

This commit is contained in:
James Peter
2026-02-11 11:23:50 +10:00
parent e82ca5ba17
commit 6dc748e8c7
13 changed files with 312 additions and 19 deletions
+40 -3
View File
@@ -93,6 +93,33 @@ fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
#[cfg(target_os = "windows")]
fn resolve_existing_path_with_pathext(path: &std::path::Path) -> Option<std::path::PathBuf> {
if is_executable_file(path) {
return Some(path.to_path_buf());
}
if path.extension().is_some() {
return None;
}
let Some(file_name) = path.file_name() else {
return None;
};
let base = file_name.to_os_string();
for ext in default_pathext_or_default() {
let mut name = base.clone();
name.push(ext);
let candidate = path.with_file_name(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn is_executable_file(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
@@ -104,6 +131,16 @@ fn is_executable_file(path: &std::path::Path) -> bool {
meta.is_file() && (meta.permissions().mode() & 0o111 != 0)
}
#[cfg(target_os = "windows")]
fn resolve_explicit_command_path(path: &std::path::Path) -> Option<std::path::PathBuf> {
resolve_existing_path_with_pathext(path)
}
#[cfg(not(target_os = "windows"))]
fn resolve_explicit_command_path(path: &std::path::Path) -> Option<std::path::PathBuf> {
is_executable_file(path).then_some(path.to_path_buf())
}
#[cfg(target_os = "windows")]
fn resolve_command_on_path(command: &str) -> Option<std::path::PathBuf> {
resolve_in_path(command)
@@ -154,7 +191,7 @@ pub(crate) fn resolve_external_agent_command_path(command: &str) -> Option<std::
if trimmed.contains(std::path::MAIN_SEPARATOR) || trimmed.contains('/') || trimmed.contains('\\') {
let path = std::path::PathBuf::from(trimmed);
return is_executable_file(&path).then_some(path);
return resolve_explicit_command_path(&path);
}
if let Some(path) = resolve_command_on_path(trimmed) {
@@ -162,8 +199,8 @@ pub(crate) fn resolve_external_agent_command_path(command: &str) -> Option<std::
}
for candidate in home_fallback_command_candidates(trimmed) {
if is_executable_file(&candidate) {
return Some(candidate);
if let Some(path) = resolve_explicit_command_path(&candidate) {
return Some(path);
}
}
+41 -5
View File
@@ -543,12 +543,16 @@ async fn process_chat_sse<S>(
let mut assistant_text = String::new();
let mut reasoning_text = String::new();
let mut current_item_id: Option<String> = None;
let mut current_response_id: Option<String> = None;
let mut current_response_model: Option<String> = None;
let mut created_emitted = false;
async fn flush_and_complete(
tx_event: &mpsc::Sender<Result<ResponseEvent>>,
assistant_text: &mut String,
reasoning_text: &mut String,
current_item_id: &Option<String>,
response_id: Option<&str>,
debug_logger: &Arc<Mutex<DebugLogger>>,
request_id: &str,
) {
@@ -590,7 +594,7 @@ async fn process_chat_sse<S>(
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
response_id: response_id.unwrap_or_default().to_string(),
token_usage: None,
}))
.await;
@@ -638,6 +642,7 @@ async fn process_chat_sse<S>(
&mut assistant_text,
&mut reasoning_text,
&current_item_id,
current_response_id.as_deref(),
&debug_logger,
&request_id,
)
@@ -669,6 +674,7 @@ async fn process_chat_sse<S>(
&mut assistant_text,
&mut reasoning_text,
&current_item_id,
current_response_id.as_deref(),
&debug_logger,
&request_id,
)
@@ -705,6 +711,30 @@ async fn process_chat_sse<S>(
let _ = logger.append_response_event(&request_id, "sse_event", &chunk);
}
if current_response_id.is_none() {
current_response_id = chunk
.get("id")
.and_then(|id| id.as_str())
.map(ToString::to_string);
}
if current_response_model.is_none() {
current_response_model = chunk
.get("model")
.and_then(|model| model.as_str())
.map(ToString::to_string);
}
if !created_emitted
&& (current_response_id.is_some() || current_response_model.is_some())
{
let _ = tx_event
.send(Ok(ResponseEvent::Created {
response_id: current_response_id.clone(),
response_model: current_response_model.clone(),
}))
.await;
created_emitted = true;
}
// Extract item_id if present at the top level or in choice
if let Some(item_id) = chunk.get("item_id").and_then(|id| id.as_str()) {
current_item_id = Some(item_id.to_string());
@@ -1080,10 +1110,16 @@ where
token_usage,
})));
}
Poll::Ready(Some(Ok(ResponseEvent::Created))) => {
// These events are exclusive to the Responses API and
// will never appear in a Chat Completions stream.
continue;
Poll::Ready(Some(Ok(ResponseEvent::Created {
response_id,
response_model,
}))) => {
// Preserve response metadata so downstream consumers can
// surface effective model routing details uniformly.
return Poll::Ready(Some(Ok(ResponseEvent::Created {
response_id,
response_model,
})));
}
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, .. }))) => {
// Always accumulate deltas so we can emit a final OutputItemDone at Completed.
+16 -3
View File
@@ -2502,8 +2502,21 @@ async fn process_sse<S>(
}
}
"response.created" => {
if event.response.is_some() {
let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
if let Some(response) = event.response {
let response_id = response
.get("id")
.and_then(Value::as_str)
.map(ToString::to_string);
let response_model = response
.get("model")
.and_then(Value::as_str)
.map(ToString::to_string);
let _ = tx_event
.send(Ok(ResponseEvent::Created {
response_id,
response_model,
}))
.await;
}
}
"response.failed" => {
@@ -3079,7 +3092,7 @@ mod tests {
}
fn is_created(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::Created)
matches!(ev, ResponseEvent::Created { .. })
}
fn is_output(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::OutputItemDone { .. })
+4 -1
View File
@@ -251,7 +251,10 @@ impl Prompt {
#[derive(Debug)]
pub enum ResponseEvent {
Created,
Created {
response_id: Option<String>,
response_model: Option<String>,
},
OutputItemDone { item: ResponseItem, sequence_number: Option<u64>, output_index: Option<u32> },
/// Indicates that the server will include reasoning content on this stream.
///
+1
View File
@@ -163,6 +163,7 @@ pub(super) struct State {
pub(super) next_internal_sub_id: u64,
pub(super) token_usage_info: Option<TokenUsageInfo>,
pub(super) latest_rate_limits: Option<RateLimitSnapshotEvent>,
pub(super) last_model_reroute_notice: Option<(String, String)>,
pub(super) pending_manual_compacts: VecDeque<String>,
pub(super) wait_interrupt_epoch: u64,
pub(super) wait_interrupt_reason: Option<WaitInterruptReason>,
+71 -2
View File
@@ -2576,6 +2576,19 @@ impl Drop for TurnLatencyGuard<'_> {
}
}
fn response_model_matches_request(requested_model: &str, response_model: &str) -> bool {
let requested = requested_model.trim().to_ascii_lowercase();
let response = response_model.trim().to_ascii_lowercase();
if response == requested {
return true;
}
response
.strip_prefix(&requested)
.is_some_and(|suffix| suffix.starts_with('-') && suffix.len() > 1)
}
async fn try_run_turn(
sess: &Session,
turn_diff_tracker: &mut TurnDiffTracker,
@@ -2639,6 +2652,11 @@ async fn try_run_turn(
};
let mut turn_latency_guard = TurnLatencyGuard::new(sess, attempt_req, prompt.as_ref());
let requested_model = prompt
.model_override
.clone()
.unwrap_or_else(|| sess.client.get_model());
let mut latest_response_model: Option<String> = None;
let mut stream = match sess.client.clone().stream(&prompt).await {
Ok(stream) => stream,
Err(e) => {
@@ -2682,7 +2700,52 @@ async fn try_run_turn(
};
match event {
ResponseEvent::Created => {}
ResponseEvent::Created {
response_id,
response_model,
} => {
if let Some(model) = response_model.clone() {
latest_response_model = Some(model.clone());
if !response_model_matches_request(&requested_model, &model) {
let should_emit_warning = {
let mut state = sess.state.lock().unwrap();
let already_warned = state
.last_model_reroute_notice
.as_ref()
.is_some_and(|(requested, response)| {
requested == &requested_model && response == &model
});
if already_warned {
false
} else {
state.last_model_reroute_notice =
Some((requested_model.clone(), model.clone()));
true
}
};
if should_emit_warning {
let warning = crate::protocol::WarningEvent {
message: format!(
"Requested model `{requested_model}` was rerouted to `{model}`. OpenAI may have rerouted you to protect against cyber abuse.\nTo verify and restore access, visit https://chatgpt.com/cyber"
),
};
let _ = sess
.tx_event
.send(sess.make_event(&sub_id, EventMsg::Warning(warning)))
.await;
}
}
}
tracing::debug!(
response_id = response_id.as_deref().unwrap_or("<none>"),
response_model = response_model.as_deref().unwrap_or("<none>"),
requested_model,
"received response.created"
);
}
ResponseEvent::ServerReasoningIncluded(_included) => {}
ResponseEvent::OutputItemDone { item, sequence_number, output_index } => {
let response =
@@ -2729,11 +2792,17 @@ async fn try_run_turn(
let (new_info, rate_limits, should_emit);
{
let mut state = sess.state.lock().unwrap();
let info = TokenUsageInfo::new_or_append(
let mut info = TokenUsageInfo::new_or_append(
&state.token_usage_info,
&token_usage,
sess.client.get_model_context_window(),
);
if let Some(info) = info.as_mut() {
info.requested_model = Some(requested_model.clone());
if let Some(response_model) = latest_response_model.clone() {
info.latest_response_model = Some(response_model);
}
}
let limits = state.latest_rate_limits.clone();
let emit = info.is_some() || limits.is_some();
state.token_usage_info = info.clone();
+14
View File
@@ -756,6 +756,9 @@ pub enum EventMsg {
/// Error while executing a submission
Error(ErrorEvent),
/// Non-fatal warning surfaced to the user.
Warning(WarningEvent),
/// Agent has started a task
TaskStarted,
@@ -896,6 +899,11 @@ pub struct ErrorEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WarningEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TaskCompleteEvent {
pub last_agent_message: Option<String>,
@@ -968,6 +976,10 @@ const BASELINE_TOKENS: u64 = 12_000;
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,
pub last_token_usage: TokenUsage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_response_model: Option<String>,
pub model_context_window: Option<u64>,
}
@@ -986,6 +998,8 @@ impl TokenUsageInfo {
None => Self {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
requested_model: None,
latest_response_model: None,
model_context_window,
},
};
@@ -56,6 +56,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
magenta: Style,
red: Style,
yellow: Style,
green: Style,
cyan: Style,
@@ -94,6 +95,7 @@ impl EventProcessorWithHumanOutput {
dimmed: Style::new().dimmed(),
magenta: Style::new().magenta(),
red: Style::new().red(),
yellow: Style::new().yellow(),
green: Style::new().green(),
cyan: Style::new().cyan(),
show_agent_reasoning: !config.hide_agent_reasoning,
@@ -115,6 +117,7 @@ impl EventProcessorWithHumanOutput {
dimmed: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
green: Style::new(),
cyan: Style::new(),
show_agent_reasoning: !config.hide_agent_reasoning,
@@ -199,6 +202,10 @@ impl EventProcessor for EventProcessorWithHumanOutput {
let prefix = "ERROR:".style(self.red);
ts_println!(self, "{prefix} {message}");
}
EventMsg::Warning(code_core::protocol::WarningEvent { message }) => {
let prefix = "WARNING:".style(self.yellow);
ts_println!(self, "{prefix} {message}");
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed));
}
@@ -309,6 +309,7 @@ async fn run_code_tool_session_inner(
}
EventMsg::AgentReasoningRawContent(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::Warning(_)
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+8
View File
@@ -1191,6 +1191,10 @@ pub struct TokenUsage {
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,
pub last_token_usage: TokenUsage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_response_model: Option<String>,
// TODO(aibrahim): make this not optional
#[ts(type = "number | null")]
pub model_context_window: Option<i64>,
@@ -1211,6 +1215,8 @@ impl TokenUsageInfo {
None => Self {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
requested_model: None,
latest_response_model: None,
model_context_window,
},
};
@@ -1244,6 +1250,8 @@ impl TokenUsageInfo {
let mut info = Self {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
requested_model: None,
latest_response_model: None,
model_context_window: Some(context_window),
};
info.fill_to_context_window(context_window);
@@ -434,7 +434,7 @@ impl ThemeSelectionView {
let mut last_err: Option<String> = None;
while let Some(ev) = stream.next().await {
match ev {
Ok(code_core::ResponseEvent::Created) => { tracing::info!("LLM: created"); let _ = progress_tx.send(ProgressMsg::SetStatus("(starting generation)".to_string())); }
Ok(code_core::ResponseEvent::Created { .. }) => { tracing::info!("LLM: created"); let _ = progress_tx.send(ProgressMsg::SetStatus("(starting generation)".to_string())); }
Ok(code_core::ResponseEvent::ReasoningSummaryDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[thinking]: {}", delta); let _ = progress_tx.send(ProgressMsg::ThinkingDelta(delta.clone())); think_sum.push_str(&delta); }
Ok(code_core::ResponseEvent::ReasoningContentDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[reasoning]: {}", delta); }
Ok(code_core::ResponseEvent::OutputTextDelta { delta, .. }) => { tracing::info!(target: "spinner", "LLM[delta]: {}", delta); let _ = progress_tx.send(ProgressMsg::OutputDelta(delta.clone())); out.push_str(&delta); }
@@ -784,7 +784,7 @@ impl ThemeSelectionView {
let mut last_err: Option<String> = None;
while let Some(ev) = stream.next().await {
match ev {
Ok(code_core::ResponseEvent::Created) => {
Ok(code_core::ResponseEvent::Created { .. }) => {
let _ = progress_tx.send(ProgressMsg::SetStatus("(starting generation)".to_string()));
}
Ok(code_core::ResponseEvent::ReasoningSummaryDelta { delta, .. }) => {
+22
View File
@@ -164,6 +164,7 @@ use code_core::protocol::CustomToolCallUpdateEvent;
use code_core::protocol::ErrorEvent;
use code_core::protocol::Event;
use code_core::protocol::EventMsg;
use code_core::protocol::WarningEvent;
use code_core::protocol::ExecApprovalRequestEvent;
use code_core::protocol::ExecCommandBeginEvent;
use code_core::protocol::ExecCommandEndEvent;
@@ -1531,6 +1532,8 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
session_requested_model: Option<String>,
session_latest_response_model: Option<String>,
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
rate_limit_warnings: RateLimitWarningState,
rate_limit_fetch_inflight: bool,
@@ -6462,6 +6465,8 @@ impl ChatWidget<'_> {
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
session_requested_model: None,
session_latest_response_model: None,
rate_limit_snapshot: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
@@ -6820,6 +6825,8 @@ impl ChatWidget<'_> {
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
session_requested_model: None,
session_latest_response_model: None,
rate_limit_snapshot: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
@@ -13785,6 +13792,12 @@ impl ChatWidget<'_> {
if let Some(info) = &event.info {
self.total_token_usage = info.total_token_usage.clone();
self.last_token_usage = info.last_token_usage.clone();
if let Some(requested_model) = &info.requested_model {
self.session_requested_model = Some(requested_model.clone());
}
if let Some(response_model) = &info.latest_response_model {
self.session_latest_response_model = Some(response_model.clone());
}
}
if let Some(snapshot) = event.rate_limits {
self.update_rate_limit_resets(&snapshot);
@@ -13850,6 +13863,10 @@ impl ChatWidget<'_> {
);
self.update_stream_token_usage_metadata();
}
EventMsg::Warning(WarningEvent { message }) => {
self.history_push_plain_state(history_cell::new_warning_event(message));
self.request_redraw();
}
EventMsg::Error(ErrorEvent { message }) => {
self.on_error(message);
}
@@ -15651,6 +15668,8 @@ impl ChatWidget<'_> {
&self.config,
&self.total_token_usage,
&self.last_token_usage,
self.session_requested_model.as_deref(),
self.session_latest_response_model.as_deref(),
));
self.history_push_plain_state(history_cell::new_prompts_output());
@@ -15930,6 +15949,8 @@ impl ChatWidget<'_> {
&self.config,
&self.total_token_usage,
&self.last_token_usage,
self.session_requested_model.as_deref(),
self.session_latest_response_model.as_deref(),
));
}
@@ -39267,6 +39288,7 @@ impl WidgetRef for &ChatWidget<'_> {
"⚙" => crate::colors::info(), // tool working
"✔" => crate::colors::success(), // tool complete
"✖" => crate::colors::error(), // error
"⚠" => crate::colors::warning(), // warning notice
"★" => crate::colors::text_bright(), // notice/popular
_ => crate::colors::text_dim(),
}
+85 -3
View File
@@ -82,6 +82,19 @@ impl PlainHistoryCell {
// Symmetric top/bottom padding for auto-review notices.
(1, 1)
}
fn is_warning_notice(&self) -> bool {
if self.state.kind != HistoryCellType::Notice {
return false;
}
self.state
.body()
.first()
.and_then(|line| line.spans.first())
.map(|span| span.text.starts_with("⚠ "))
.unwrap_or(false)
}
pub(crate) fn from_state(state: PlainMessageState) -> Self {
let mut kind = history_cell_kind_from_plain(state.kind);
if kind == HistoryCellType::User {
@@ -270,6 +283,9 @@ impl HistoryCell for PlainHistoryCell {
}
fn gutter_symbol(&self) -> Option<&'static str> {
if self.is_warning_notice() {
return Some("⚠");
}
if let Some(header) = self.state.header() {
let label = header.label.trim().to_lowercase();
if label == "auto review" {
@@ -289,7 +305,15 @@ impl HistoryCell for PlainHistoryCell {
}
}
lines.extend(message_lines_to_ratatui(self.state.body(), &theme));
let mut body_lines = message_lines_to_ratatui(self.state.body(), &theme);
if self.is_warning_notice()
&& let Some(first_line) = body_lines.first_mut()
&& let Some(first_span) = first_line.spans.first_mut()
&& let Some(stripped) = first_span.content.as_ref().strip_prefix("⚠ ")
{
first_span.content = stripped.to_string().into();
}
lines.extend(body_lines);
lines
}
@@ -751,10 +775,25 @@ pub(crate) fn new_model_output(model: &str, effort: ReasoningEffort) -> PlainMes
plain_message_state_from_lines(lines, HistoryCellType::Notice)
}
fn response_model_matches_request(requested_model: &str, response_model: &str) -> bool {
let requested = requested_model.trim().to_ascii_lowercase();
let response = response_model.trim().to_ascii_lowercase();
if response == requested {
return true;
}
response
.strip_prefix(&requested)
.is_some_and(|suffix| suffix.starts_with('-') && suffix.len() > 1)
}
pub(crate) fn new_status_output(
config: &Config,
total_usage: &TokenUsage,
last_usage: &TokenUsage,
requested_model: Option<&str>,
latest_response_model: Option<&str>,
) -> PlainMessageState {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -819,6 +858,43 @@ pub(crate) fn new_status_output(
lines.push(Line::from(""));
// 🔁 Model routing
lines.push(Line::from(vec!["🔁 ".into(), "Model Routing".bold()]));
let requested_display = requested_model.unwrap_or(config.model.as_str());
lines.push(Line::from(vec![
" • Requested model: ".into(),
requested_display.to_string().into(),
]));
if let Some(response_model) = latest_response_model {
let same_model = response_model_matches_request(requested_display, response_model);
let (match_marker, match_label, match_style) = if same_model {
(
"✓",
"requested model matches response",
Style::default().fg(colors::success()),
)
} else {
(
"✗",
"requested model does not match response",
Style::default().fg(colors::error()),
)
};
lines.push(Line::from(vec![
" • Latest response model: ".into(),
response_model.to_string().into(),
]));
lines.push(Line::from(vec![
" • Match: ".into(),
Span::styled(format!("{match_marker} {match_label}"), match_style),
]));
} else {
lines.push(Line::from(" • Latest response model: unavailable"));
lines.push(Line::from(" • Match: ? waiting for a completed response"));
}
lines.push(Line::from(""));
// 🔐 Authentication
lines.push(Line::from(vec!["🔐 ".into(), "Authentication".bold()]));
{
@@ -976,9 +1052,15 @@ pub(crate) fn new_status_output(
pub(crate) fn new_warning_event(message: String) -> PlainMessageState {
let warn_style = Style::default().fg(crate::colors::warning());
let mut lines: Vec<Line<'static>> = Vec::with_capacity(2);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("notice"));
lines.push(Line::from(vec![Span::styled(format!("⚠ {message}"), warn_style)]));
let mut message_lines = message.lines();
if let Some(first) = message_lines.next() {
lines.push(Line::from(vec![Span::styled(format!("⚠ {first}"), warn_style)]));
lines.extend(message_lines.map(|line| Line::from(vec![Span::styled(line.to_string(), warn_style)])));
}
plain_message_state_from_lines(lines, HistoryCellType::Notice)
}