Adding logging when we crash in bedrock, adding open AI request translator changes and AI page settings cleanup
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod convert;
|
||||
pub mod crash_log;
|
||||
pub mod diagnostic;
|
||||
pub mod external_config;
|
||||
pub mod models;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user