mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(model-routing): surface reroute warnings and status models
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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.
|
||||
|
||||
@@ -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 { .. })
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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, .. }) => {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user