Adding logging when we crash in bedrock, adding open AI request translator changes and AI page settings cleanup

This commit is contained in:
Ryan Ward
2026-07-08 15:19:51 -05:00
parent bc83792d7a
commit 40bd86f662
11 changed files with 686 additions and 287 deletions
+9
View File
@@ -268,6 +268,15 @@ impl BedrockClient {
);
}
}
super::crash_log::log_crash(
"BedrockApiError",
&msg,
&effective_model_id,
messages.len(),
None,
);
if msg.contains("AccessDenied") || msg.contains("access denied") {
BedrockError::AccessDenied(msg)
} else if msg.contains("ThrottlingException") || msg.contains("throttl") {
+95
View File
@@ -0,0 +1,95 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use chrono::Local;
const CRASH_LOG_DIR: &str = "crash-logs";
const MAX_CRASH_LOGS: usize = 20;
fn crash_log_dir() -> Option<PathBuf> {
galaxy_core::paths::galaxy_home_config_dir().map(|dir| dir.join(CRASH_LOG_DIR))
}
pub fn log_crash(
error_type: &str,
error_message: &str,
model_id: &str,
message_count: usize,
context_tokens: Option<u32>,
) {
let Some(dir) = crash_log_dir() else {
log::warn!("[crash-log] Could not determine crash log directory");
return;
};
if let Err(e) = fs::create_dir_all(&dir) {
log::warn!("[crash-log] Failed to create crash log directory: {e}");
return;
}
rotate_logs(&dir);
let timestamp = Local::now();
let filename = format!("crash_{}.log", timestamp.format("%Y%m%d_%H%M%S"));
let path = dir.join(&filename);
let content = format!(
"=== Galaxy Crash Log ===\n\
Timestamp: {}\n\
Error Type: {}\n\
Model: {}\n\
Message Count: {}\n\
Context Tokens: {}\n\
\n\
Error Details:\n\
{}\n\
========================\n",
timestamp.format("%Y-%m-%d %H:%M:%S %Z"),
error_type,
model_id,
message_count,
context_tokens
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown".to_string()),
error_message,
);
match OpenOptions::new().create(true).write(true).open(&path) {
Ok(mut file) => {
if let Err(e) = file.write_all(content.as_bytes()) {
log::warn!("[crash-log] Failed to write crash log: {e}");
} else {
log::info!("[crash-log] Wrote crash log to {}", path.display());
}
}
Err(e) => {
log::warn!("[crash-log] Failed to open crash log file: {e}");
}
}
}
fn rotate_logs(dir: &std::path::Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut logs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash_") && n.ends_with(".log"))
})
.collect();
logs.sort();
while logs.len() >= MAX_CRASH_LOGS {
if let Some(oldest) = logs.first() {
let _ = fs::remove_file(oldest);
logs.remove(0);
}
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod client;
pub mod convert;
pub mod crash_log;
pub mod diagnostic;
pub mod external_config;
pub mod models;
+35
View File
@@ -1562,6 +1562,41 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
None => ("Agent completed.".to_string(), false),
}
}
api::request::input::tool_call_result::Result::AskUserQuestion(ask_result) => {
match &ask_result.result {
Some(api::ask_user_question_result::Result::Success(success)) => {
let answers_text: Vec<String> = success
.answers
.iter()
.map(|item| {
let answer_str = match &item.answer {
Some(api::ask_user_question_result::answer_item::Answer::MultipleChoice(mc)) => {
let mut parts = mc.selected_options.clone();
if !mc.other_text.is_empty() {
parts.push(mc.other_text.clone());
}
parts.join(", ")
}
Some(api::ask_user_question_result::answer_item::Answer::Skipped(_)) => {
"Skipped".to_string()
}
None => "No answer provided".to_string(),
};
if item.question_id.is_empty() {
answer_str
} else {
format!("{}: {}", item.question_id, answer_str)
}
})
.collect();
(format!("User's answers:\n{}", answers_text.join("\n")), false)
}
Some(api::ask_user_question_result::Result::Error(error)) => {
(format!("User question error: {}", error.message), true)
}
None => ("User did not answer.".to_string(), true),
}
}
_ => ("Tool completed successfully.".to_string(), false),
}
} else {
@@ -397,6 +397,13 @@ pub fn bedrock_stream_to_response_events(
);
}
}
super::crash_log::log_crash(
"BedrockStreamError",
&format!("{e}"),
&model_id,
event_count as usize,
Some(input_tokens as u32),
);
if !buffered_text.is_empty() {
let msg_id = current_text_message_id
.clone()
+162 -34
View File
@@ -404,6 +404,8 @@ pub struct BlocklistAIController {
/// Per-conversation loop detection state for preventing recursive tool failures.
loop_detection: HashMap<AIConversationId, LoopDetectionState>,
/// Per-conversation error retry count for injecting corrective messages on failure.
error_retry_counts: HashMap<AIConversationId, usize>,
/// Passive suggestion results that should be included with the next request
/// for a given conversation (e.g. accepted/iterated code diffs that weren't
/// auto-resumed).
@@ -684,6 +686,7 @@ impl BlocklistAIController {
pending_passive_follow_ups: HashSet::new(),
pending_passive_suggestion_results: HashMap::new(),
loop_detection: HashMap::new(),
error_retry_counts: HashMap::new(),
}
}
@@ -1233,8 +1236,9 @@ impl BlocklistAIController {
queued_query_id: Option<QueuedQueryId>,
ctx: &mut ModelContext<Self>,
) {
// User sending a new query resets loop detection — fresh context.
// User sending a new query resets loop detection and error retry state — fresh context.
self.loop_detection.remove(&conversation_id);
self.error_retry_counts.remove(&conversation_id);
let is_viewer = self
.terminal_model
@@ -3132,42 +3136,145 @@ impl BlocklistAIController {
});
}
// A resume scheduled for this failure keeps the conversation in
// the non-terminal TransientError status instead of Error.
let recovery_pending = response_stream
.as_ref(ctx)
.should_resume_conversation_after_stream_finished();
let mut renderable_error: RenderableAIError = (&e).into();
if let RenderableAIError::Other {
will_attempt_resume,
waiting_for_network,
..
}
| RenderableAIError::TransientNetworkError {
will_attempt_resume,
waiting_for_network,
..
} = &mut renderable_error
{
// Rendering-only hints; state machine consumers key off the
// TransientError conversation status instead.
*will_attempt_resume |= recovery_pending;
if recovery_pending {
let network_status = NetworkStatus::as_ref(ctx);
*waiting_for_network = !network_status.is_online();
}
}
// Check if this error is eligible for corrective retry.
// Similar to loop detection, inject a message telling the LLM
// to try a different approach rather than just failing.
let error_str = format!("{e}");
let is_corrective_retry_candidate = !matches!(
e.as_ref(),
AIApiError::QuotaLimit { .. }
) && (error_str.contains("ValidationException")
|| error_str.contains("validation")
|| error_str.contains("context window")
|| error_str.contains("too many tokens")
|| error_str.contains("input is too long")
|| error_str.contains("throttl")
|| error_str.contains("ThrottlingException"));
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
renderable_error,
recovery_pending,
&stream_id,
const MAX_ERROR_RETRIES: usize = 2;
let retry_count = self
.error_retry_counts
.entry(conversation_id)
.or_insert(0);
let should_corrective_retry =
is_corrective_retry_candidate && *retry_count < MAX_ERROR_RETRIES;
if should_corrective_retry {
*retry_count += 1;
let retry_num = *retry_count;
log::warn!(
"[error-retry] Attempting corrective retry {}/{} for conversation {:?}: {}",
retry_num,
MAX_ERROR_RETRIES,
conversation_id,
self.terminal_surface_id,
ctx,
error_str
);
});
// Mark the error on the conversation but with recovery pending
let renderable_error = RenderableAIError::Other {
error_message: format!(
"Error encountered, retrying with different approach (attempt {}/{})",
retry_num, MAX_ERROR_RETRIES
),
will_attempt_resume: true,
waiting_for_network: false,
is_user_error: false,
};
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
renderable_error,
/*recovery_pending*/ true,
&stream_id,
conversation_id,
self.terminal_surface_id,
ctx,
);
});
// Inject a corrective message and resume
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
{
let root_task_id = conversation.get_root_task_id().clone();
let corrective_msg = format!(
"[SYSTEM] The previous request resulted in an error: {}\n\n\
Please try a completely different approach to accomplish the goal. \
If the error is related to context size, reduce the amount of content \
you are working with (read fewer files, use smaller commands, break \
the task into smaller steps). If you cannot find an alternative, \
explain to the user what is failing and why.",
error_str
);
let inputs = vec![
AIAgentInput::UserQuery {
query: corrective_msg,
context: Arc::from([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
},
];
let _ = self.send_request_input(
RequestInput::for_task(
inputs,
root_task_id,
&self.active_session,
self.get_current_response_initiator(),
conversation_id,
self.terminal_surface_id,
ctx,
),
None,
/*can_attempt_resume_on_error*/ false,
/*is_queued_prompt*/ false,
ctx,
);
}
} else {
// Clear retry count on non-retryable errors or exhausted retries
self.error_retry_counts.remove(&conversation_id);
// A resume scheduled for this failure keeps the conversation in
// the non-terminal TransientError status instead of Error.
let recovery_pending = response_stream
.as_ref(ctx)
.should_resume_conversation_after_stream_finished();
let mut renderable_error: RenderableAIError = (&e).into();
if let RenderableAIError::Other {
will_attempt_resume,
waiting_for_network,
..
}
| RenderableAIError::TransientNetworkError {
will_attempt_resume,
waiting_for_network,
..
} = &mut renderable_error
{
// Rendering-only hints; state machine consumers key off the
// TransientError conversation status instead.
*will_attempt_resume |= recovery_pending;
if recovery_pending {
let network_status = NetworkStatus::as_ref(ctx);
*waiting_for_network = !network_status.is_online();
}
}
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
renderable_error,
recovery_pending,
&stream_id,
conversation_id,
self.terminal_surface_id,
ctx,
);
});
}
}
}
}
@@ -3510,6 +3617,13 @@ impl BlocklistAIController {
}
Some(warp_multi_agent_api::response_event::stream_finished::Reason::ContextWindowExceeded(_)) => {
let error_message = "Input exceeded context window limit.";
crate::ai::bedrock::crash_log::log_crash(
"ContextWindowExceeded",
error_message,
"unknown",
0,
None,
);
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
RenderableAIError::ContextWindowExceeded(error_message.to_owned()),
@@ -3596,6 +3710,13 @@ impl BlocklistAIController {
let error_message = format!(
"Response stream finished unexpectedly with internal error: {message}",
);
crate::ai::bedrock::crash_log::log_crash(
"InternalError",
&error_message,
"unknown",
0,
None,
);
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
RenderableAIError::Other {
@@ -3614,6 +3735,13 @@ impl BlocklistAIController {
}
Some(warp_multi_agent_api::response_event::stream_finished::Reason::MaxTokenLimit(_)) => {
let error_message = "Input exceeded context window limit.";
crate::ai::bedrock::crash_log::log_crash(
"MaxTokenLimit",
error_message,
"unknown",
0,
None,
);
history_model.update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error(
RenderableAIError::ContextWindowExceeded(error_message.to_owned()),
@@ -16,6 +16,7 @@ use crate::ai::agent::api::{self, generate_multi_agent_output, ConvertToAPITypeE
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{AIIdentifiers, CancellationReason};
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::llms::LLMPreferences;
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::provider::ProviderConfig;
use crate::network::NetworkStatus;
@@ -174,32 +175,18 @@ impl ResponseStream {
fn resolve_provider_config(model_id: &str, ctx: &ModelContext<Self>) -> ProviderConfig {
let settings = AISettings::as_ref(ctx);
// Check if OpenAI/LiteLLM provider is enabled
// Check if this specific model has an OpenAI-compatible routing entry.
// This allows OpenAI/LiteLLM models to coexist with Bedrock models —
// only models fetched from the OpenAI endpoint route through it.
if *settings.openai_enabled.value() {
let base_url = settings.openai_base_url.value().clone();
let api_key = {
let key = settings.openai_api_key.value().clone();
if key.is_empty() {
None
} else {
Some(key)
}
};
// Use the model override from settings if set, otherwise use the selected model ID.
// This allows LiteLLM models to pass through their actual model_id to the proxy.
let model = {
let m = settings.openai_model.value().clone();
if m.is_empty() {
Some(model_id.to_string())
} else {
Some(m)
}
};
return ProviderConfig::OpenAI(OpenAIClientConfig {
base_url,
api_key,
model,
});
let llm_prefs = LLMPreferences::as_ref(ctx);
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
model: Some(model_id.to_string()),
});
}
}
// Fall back to Bedrock
+192 -20
View File
@@ -21,7 +21,7 @@ use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider;
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
use crate::server::server_api::ServerApiProvider;
use crate::settings::{BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig};
use crate::settings::{BedrockModelConfig, OpenAIModelConfig};
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::{report_error, AISettings};
@@ -601,6 +601,10 @@ pub struct LLMPreferences {
custom_model_routers: Vec<CustomModelRouter>,
#[cfg(not(target_family = "wasm"))]
openai_provider_routing: HashMap<String, super::openai::client::OpenAIClientConfig>,
/// Models fetched from the OpenAI-compatible /models endpoint at runtime.
/// Stored in memory only — not persisted to TOML.
#[cfg(not(target_family = "wasm"))]
fetched_openai_models: Vec<OpenAIModelConfig>,
}
impl LLMPreferences {
@@ -657,6 +661,28 @@ impl LLMPreferences {
});
}
// Re-inject provider models when Bedrock or OpenAI enabled state changes.
#[cfg(not(target_family = "wasm"))]
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
use crate::settings::AISettingsChangedEvent;
if matches!(
event,
AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::OpenAIEnabled { .. }
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
) {
me.inject_bedrock_models(ctx);
me.inject_openai_models(ctx);
if matches!(event, AISettingsChangedEvent::OpenAIEnabled { .. } | AISettingsChangedEvent::OpenAIBaseUrl { .. }) {
me.fetch_openai_models_from_endpoint(ctx);
}
// Safety: ensure the default model is still present in choices.
// If all provider models were removed, the default_id would dangle.
me.ensure_default_model_present();
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
});
let base_llm_for_terminal_view = HashMap::new();
let custom_llms = build_custom_llm_infos(ApiKeyManager::as_ref(ctx).keys());
@@ -668,6 +694,8 @@ impl LLMPreferences {
custom_model_routers: Vec::new(),
#[cfg(not(target_family = "wasm"))]
openai_provider_routing: HashMap::new(),
#[cfg(not(target_family = "wasm"))]
fetched_openai_models: Vec::new(),
};
// Seed from any already-loaded local config (the async load emits
@@ -688,6 +716,7 @@ impl LLMPreferences {
Self::ensure_default_models_in_settings(ctx);
me.inject_bedrock_models(ctx);
me.inject_openai_models(ctx);
me.fetch_openai_models_from_endpoint(ctx);
}
me
@@ -932,27 +961,11 @@ impl LLMPreferences {
return;
}
// Collect all (provider_name, base_url, api_key, models) tuples from both config paths.
// Models come exclusively from the in-memory /models endpoint fetch.
let mut provider_entries: Vec<(String, String, Option<String>, Vec<OpenAIModelConfig>)> =
Vec::new();
// Path 1: Multi-provider `ai.providers[]`
let providers: Vec<OpenAIProviderConfig> = settings.openai_providers.value().clone();
for provider in providers {
if provider.models.is_empty() {
continue;
}
provider_entries.push((
provider.name,
provider.base_url,
provider.api_key,
provider.models,
));
}
// Path 2: Legacy single-provider `ai.openai.{base_url, models}`
let legacy_models: Vec<OpenAIModelConfig> = settings.openai_models.value().clone();
if !legacy_models.is_empty() {
if !self.fetched_openai_models.is_empty() {
let base_url = settings.openai_base_url.value().clone();
let api_key = {
let key = settings.openai_api_key.value().clone();
@@ -967,7 +980,7 @@ impl LLMPreferences {
} else {
"LiteLLM".to_string()
};
provider_entries.push((name, base_url, api_key, legacy_models));
provider_entries.push((name, base_url, api_key, self.fetched_openai_models.clone()));
}
if provider_entries.is_empty() {
@@ -975,6 +988,7 @@ impl LLMPreferences {
}
let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new();
for (provider_name, base_url, api_key, models) in provider_entries {
let client_config = OpenAIClientConfig {
base_url: base_url.clone(),
@@ -983,6 +997,10 @@ impl LLMPreferences {
};
for model in &models {
if !seen_model_ids.insert(model.model_id.clone()) {
continue;
}
// Register the routing entry
self.openai_provider_routing
.insert(model.model_id.clone(), client_config.clone());
@@ -1026,6 +1044,36 @@ impl LLMPreferences {
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
}
/// Ensures the default model ID in each feature's choices still points to
/// an existing entry. If the default was removed (e.g. provider disabled),
/// switch to the first remaining choice.
#[cfg(not(target_family = "wasm"))]
fn ensure_default_model_present(&mut self) {
fn fix_default(feature: &mut AvailableLLMs) {
if feature.choices.is_empty() {
return;
}
let default_exists = feature
.choices
.iter()
.any(|m| m.id == feature.default_id);
if !default_exists {
let new_default = feature.choices[0].id.clone();
log::info!(
"[llm] Default model {:?} no longer available, switching to {:?}",
feature.default_id,
new_default
);
feature.default_id = new_default;
}
}
fix_default(&mut self.models_by_feature.agent_mode);
fix_default(&mut self.models_by_feature.coding);
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
fix_default(cli);
}
}
/// Returns the OpenAI client config for a given model ID, if it was injected
/// from an OpenAI-compatible provider.
#[cfg(not(target_family = "wasm"))]
@@ -1036,6 +1084,130 @@ impl LLMPreferences {
self.openai_provider_routing.get(model_id)
}
/// Fetches available models from the configured OpenAI-compatible /models endpoint
/// and stores them in memory. Called at startup and when the user clicks "Fetch Models".
#[cfg(not(target_family = "wasm"))]
pub fn fetch_openai_models_from_endpoint(&mut self, ctx: &mut ModelContext<Self>) {
let settings = AISettings::as_ref(ctx);
if !*settings.openai_enabled.value() {
return;
}
let base_url = settings.openai_base_url.value().clone();
if base_url.is_empty() {
return;
}
let api_key = {
let key = settings.openai_api_key.value().clone();
if key.is_empty() {
None
} else {
Some(key)
}
};
let _ = ctx.spawn(
async move {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
let mut request = client.get(&url);
if let Some(ref key) = api_key {
request = request.header("Authorization", format!("Bearer {key}"));
}
let response = match request.send().await {
Ok(r) => r,
Err(e) => {
log::warn!("[openai/litellm] Failed to fetch models from endpoint: {e}");
return Vec::new();
}
};
if !response.status().is_success() {
log::warn!(
"[openai/litellm] Model fetch returned HTTP {}",
response.status()
);
return Vec::new();
}
let body: serde_json::Value = match response.json().await {
Ok(v) => v,
Err(e) => {
log::warn!("[openai/litellm] Failed to parse models response: {e}");
return Vec::new();
}
};
let models: Vec<OpenAIModelConfig> = body["data"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?;
let context_size = m["max_model_len"]
.as_u64()
.or_else(|| m["context_window"].as_u64())
.or_else(|| m["max_input_tokens"].as_u64())
.unwrap_or(200_000) as u32;
let display_name = id
.split('/')
.next_back()
.unwrap_or(id)
.replace(['-', '_'], " ");
let display_name = display_name
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
let provider = if id.contains("claude") || id.contains("anthropic") {
Some("anthropic".to_string())
} else if id.contains("gpt") || id.contains("o1") || id.contains("o3") {
Some("openai".to_string())
} else if id.contains("gemini") {
Some("google".to_string())
} else {
None
};
Some(OpenAIModelConfig {
model_id: id.to_string(),
display_name,
vision_supported: m["supports_vision"].as_bool().unwrap_or(false),
context_size,
provider,
})
})
.collect();
log::info!(
"[openai/litellm] Fetched {} model(s) from endpoint",
models.len()
);
models
},
|me, models, ctx| {
if !models.is_empty() {
me.fetched_openai_models = models;
me.inject_openai_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
},
);
}
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
pub fn get_active_base_model<'a>(
&'a self,
+113 -33
View File
@@ -49,41 +49,125 @@ fn remove_orphaned_tool_results(messages: &mut Vec<ConversationMessage>) {
}
/// For any assistant tool_use that doesn't have a matching tool_result in a
/// subsequent user message, synthesize an error result.
/// subsequent user message, synthesize a result immediately after the tool_use.
/// This satisfies Bedrock's requirement (via LiteLLM) that tool_result blocks
/// appear immediately after the corresponding tool_use message.
fn synthesize_missing_tool_results(messages: &mut Vec<ConversationMessage>) {
let mut pending_tool_use_ids: Vec<(String, usize)> = Vec::new();
let mut answered_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
// Collect all tool_use IDs and all answered IDs
for (i, msg) in messages.iter().enumerate() {
match msg.role {
MessageRole::Assistant => {
collect_tool_use_ids_with_index(&msg.content, i, &mut pending_tool_use_ids);
}
MessageRole::User => {
collect_tool_result_ids(&msg.content, &mut answered_ids);
}
// First pass: collect all existing tool_result IDs
for msg in messages.iter() {
if msg.role == MessageRole::User {
collect_tool_result_ids(&msg.content, &mut answered_ids);
}
}
// Find unanswered tool_uses and synthesize results
let mut synthetic_results: Vec<ConversationMessage> = Vec::new();
for (tool_use_id, _) in pending_tool_use_ids {
if !answered_ids.contains(&tool_use_id) {
synthetic_results.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id,
content: "Tool call result unavailable (conversation was interrupted)."
.to_string(),
is_error: true,
// Second pass: walk through messages and insert synthetic results after
// assistant tool_use messages that have unanswered IDs.
let mut i = 0;
while i < messages.len() {
if messages[i].role != MessageRole::Assistant {
i += 1;
continue;
}
let mut unanswered: Vec<String> = Vec::new();
collect_tool_use_ids_vec(&messages[i].content, &mut unanswered);
unanswered.retain(|id| !answered_ids.contains(id));
if unanswered.is_empty() {
i += 1;
continue;
}
log::warn!(
"[openai] Synthesizing {} missing tool_result(s) after message {} for IDs: {:?}",
unanswered.len(),
i,
unanswered
);
let synthetic_parts: Vec<ContentPart> = unanswered
.iter()
.map(|id| ContentPart::ToolResult {
tool_use_id: id.clone(),
content: "Tool call result unavailable (conversation was interrupted).".to_string(),
is_error: true,
})
.collect();
let insert_idx = i + 1;
// If next message is a user message, merge synthetic results into it
if insert_idx < messages.len() && messages[insert_idx].role == MessageRole::User {
match &mut messages[insert_idx].content {
MessageContent::MultiPart(parts) => {
let existing = std::mem::take(parts);
parts.extend(synthetic_parts);
parts.extend(existing);
}
existing => {
let existing_part =
match std::mem::replace(existing, MessageContent::Text(String::new())) {
MessageContent::Text(t) => ContentPart::Text(t),
MessageContent::ToolResult {
tool_use_id,
content,
is_error,
} => ContentPart::ToolResult {
tool_use_id,
content,
is_error,
},
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => ContentPart::ToolUse {
tool_use_id,
name,
input,
},
MessageContent::MultiPart(_) => unreachable!(),
};
let mut parts = synthetic_parts;
parts.push(existing_part);
*existing = MessageContent::MultiPart(parts);
}
}
} else {
// No user message follows — insert a new one
let content = if synthetic_parts.len() == 1 {
match synthetic_parts.into_iter().next().unwrap() {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => MessageContent::ToolResult {
tool_use_id,
content,
is_error,
},
_ => unreachable!(),
}
} else {
MessageContent::MultiPart(synthetic_parts)
};
messages.insert(
insert_idx,
ConversationMessage {
role: MessageRole::User,
content,
},
});
);
}
}
if !synthetic_results.is_empty() {
messages.extend(synthetic_results);
// Mark these as answered so we don't double-synthesize
for id in unanswered {
answered_ids.insert(id);
}
i += 2; // Skip past the inserted/modified message
}
}
@@ -103,19 +187,15 @@ fn collect_tool_use_ids(content: &MessageContent, ids: &mut std::collections::Ha
}
}
fn collect_tool_use_ids_with_index(
content: &MessageContent,
index: usize,
ids: &mut Vec<(String, usize)>,
) {
fn collect_tool_use_ids_vec(content: &MessageContent, ids: &mut Vec<String>) {
match content {
MessageContent::ToolUse { tool_use_id, .. } => {
ids.push((tool_use_id.clone(), index));
ids.push(tool_use_id.clone());
}
MessageContent::MultiPart(parts) => {
for part in parts {
if let ContentPart::ToolUse { tool_use_id, .. } = part {
ids.push((tool_use_id.clone(), index));
ids.push(tool_use_id.clone());
}
}
}
+5 -110
View File
@@ -2732,118 +2732,13 @@ impl AISettingsPageView {
}
}
/// Fetches models from the LiteLLM endpoint and updates settings.
/// Fetches models from the LiteLLM endpoint and stores them in memory via LLMPreferences.
fn fetch_litellm_models(&mut self, ctx: &mut ViewContext<Self>) {
let settings = AISettings::as_ref(ctx);
let base_url = settings.openai_base_url.value().clone();
let api_key = {
let key = settings.openai_api_key.value().clone();
if key.is_empty() {
None
} else {
Some(key)
}
};
use crate::ai::llms::LLMPreferences;
let _ = ctx.spawn(
async move {
use crate::settings::ai::OpenAIModelConfig;
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let mut request = client.get(&url);
if let Some(ref key) = api_key {
request = request.header("Authorization", format!("Bearer {key}"));
}
let response = match request.send().await {
Ok(r) => r,
Err(e) => {
log::error!("[litellm] Failed to fetch models: {e}");
return Vec::new();
}
};
if !response.status().is_success() {
log::error!("[litellm] Model fetch returned HTTP {}", response.status());
return Vec::new();
}
let body: serde_json::Value = match response.json().await {
Ok(v) => v,
Err(e) => {
log::error!("[litellm] Failed to parse models response: {e}");
return Vec::new();
}
};
// LiteLLM /models endpoint returns OpenAI-compatible format:
// { "data": [{ "id": "model-name", "max_model_len": N, ... }] }
let models: Vec<OpenAIModelConfig> = body["data"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|m| {
let id = m["id"].as_str()?;
// Try multiple context window fields used by different proxies
let context_size = m["max_model_len"]
.as_u64()
.or_else(|| m["context_window"].as_u64())
.or_else(|| m["max_input_tokens"].as_u64())
.unwrap_or(200_000) as u32;
// Derive display name from model ID
let display_name = id
.split('/')
.next_back()
.unwrap_or(id)
.replace(['-', '_'], " ");
// Capitalize first letter of each word
let display_name = display_name
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
// Infer provider from model ID prefix
let provider = if id.contains("claude") || id.contains("anthropic") {
Some("anthropic".to_string())
} else if id.contains("gpt") || id.contains("o1") || id.contains("o3") {
Some("openai".to_string())
} else if id.contains("gemini") {
Some("google".to_string())
} else {
None
};
Some(OpenAIModelConfig {
model_id: id.to_string(),
display_name,
vision_supported: m["supports_vision"].as_bool().unwrap_or(false),
context_size,
provider,
})
})
.collect();
log::info!("[litellm] Fetched {} model(s) from {}", models.len(), url);
models
},
|_view, models, ctx| {
if !models.is_empty() {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.openai_models.set_value(models, ctx);
});
}
ctx.notify();
},
);
LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| {
llm_prefs.fetch_openai_models_from_endpoint(ctx);
});
}
fn build_page(