From e9a9a4c30f0100787b860ebeefa59bc8076e1c1c Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 15 Jul 2026 16:16:51 -0500 Subject: [PATCH] Bump version to 2.0.0 and upload install-galaxy.sh in deploy script - Update version from 1.6.3 to 2.0.0 in app/Cargo.toml and Cargo.lock - Add install-galaxy.sh upload step to build-and-deploy-hermes script - Include pending AI provider and agent changes --- Cargo.lock | 2 +- app/Cargo.toml | 2 +- app/src/ai/agent/conversation.rs | 2 +- app/src/ai/bedrock/response_translator.rs | 3 +- app/src/ai/bedrock/translator.rs | 4 + .../agent_view/agent_input_footer/mod.rs | 2 +- app/src/ai/blocklist/controller.rs | 25 +-- .../blocklist/controller/response_stream.rs | 2 + .../usage/conversation_usage_view.rs | 5 +- app/src/ai/llms.rs | 125 ++++++++++--- app/src/ai/llms_tests.rs | 172 ++++++++++++++++++ app/src/ai/openai/client.rs | 2 + app/src/ai/openai/response_translator.rs | 17 +- app/src/ai/openai/translator.rs | 15 +- app/src/ai/provider/types.rs | 76 ++++++++ app/src/settings/ai.rs | 25 ++- app/src/terminal/input/agent.rs | 30 ++- script/build-and-deploy-hermes/src/index.tsx | 73 ++++++++ script/install-galaxy.sh | 2 - 19 files changed, 523 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af6adcd8..621f9131 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "galaxy" -version = "1.6.3" +version = "2.0.0" dependencies = [ "addr", "aha-reqwest-eventsource", diff --git a/app/Cargo.toml b/app/Cargo.toml index 688b0498..5b51af1d 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal" edition = "2021" autobins = false name = "galaxy" -version = "1.6.3" +version = "2.0.0" publish.workspace = true license.workspace = true diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 2643a29a..baa5c462 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -2250,7 +2250,7 @@ impl AIConversation { if let Some(usage_metadata) = usage_metadata { self.conversation_usage_metadata.context_window_usage = - usage_metadata.context_window_usage; + usage_metadata.context_window_usage.clamp(0.0, 1.0); self.conversation_usage_metadata.credits_spent = usage_metadata.credits_spent; self.conversation_usage_metadata.platform_credits_spent = usage_metadata.platform_credits_spent; diff --git a/app/src/ai/bedrock/response_translator.rs b/app/src/ai/bedrock/response_translator.rs index 0bbe47c5..383398ec 100644 --- a/app/src/ai/bedrock/response_translator.rs +++ b/app/src/ai/bedrock/response_translator.rs @@ -678,7 +678,8 @@ pub fn build_stream_finished( / max_context_tokens as f32 } else { 0.0 - }; + } + .clamp(0.0, 1.0); #[allow(deprecated)] let conversation_usage_metadata = Some(stream_finished::ConversationUsageMetadata { diff --git a/app/src/ai/bedrock/translator.rs b/app/src/ai/bedrock/translator.rs index 81d989d8..00111be3 100644 --- a/app/src/ai/bedrock/translator.rs +++ b/app/src/ai/bedrock/translator.rs @@ -98,6 +98,10 @@ pub async fn execute( messages.extend(new_input_messages); } + for message in &mut messages { + message.truncate_tool_results_for_provider_request(); + } + request_translator::sanitize_messages_for_bedrock(&mut messages); let system_prompt = request_translator::extract_system_prompt(request); diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index 5fd92b77..1e378192 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -1942,7 +1942,7 @@ impl AgentInputFooter { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.terminal_view_id) { - let usage = conversation.context_window_usage(); + let usage = conversation.context_window_usage().clamp(0.0, 1.0); let icon = icon_for_context_window_usage(usage); let remaining_pct = ((1.0 - usage) * 100.0).round() as i32; diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 7bc4293e..4606120b 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -3139,17 +3139,20 @@ impl BlocklistAIController { // 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. + // Exclude errors that are proxy/config issues (cache_control, + // BadRequestError from LiteLLM) since the LLM can't fix those. 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")); + let is_proxy_config_error = error_str.contains("cache_control") + || error_str.contains("tool_use` ids were found without") + || error_str.contains("BadRequestError"); + let is_corrective_retry_candidate = !is_proxy_config_error + && !matches!(e.as_ref(), AIApiError::QuotaLimit { .. }) + && (error_str.contains("ValidationException") + || 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")); const MAX_ERROR_RETRIES: usize = 2; let retry_count = self @@ -4026,7 +4029,7 @@ impl BlocklistAIController { let max_ctx = context_window_for_model(&active_model_id); let new_usage = (summary_tokens + remaining_msgs_tokens) as f32 / max_ctx as f32; - conversation.set_context_window_usage(new_usage); + conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0)); conversation .set_current_context_tokens(summary_tokens + remaining_msgs_tokens); diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index b40af2f7..3d816efc 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -185,6 +185,8 @@ impl ResponseStream { base_url: client_config.base_url.clone(), api_key: client_config.api_key.clone(), model: Some(model_id.to_string()), + max_input_tokens: client_config.max_input_tokens, + max_output_tokens: client_config.max_output_tokens, }); } } diff --git a/app/src/ai/blocklist/usage/conversation_usage_view.rs b/app/src/ai/blocklist/usage/conversation_usage_view.rs index 6db948b6..fe71701c 100644 --- a/app/src/ai/blocklist/usage/conversation_usage_view.rs +++ b/app/src/ai/blocklist/usage/conversation_usage_view.rs @@ -522,7 +522,8 @@ impl ConversationUsageView { } labels.push(render_label_text("Context window used", appearance)); - let context_usage_pct = self.usage_info.context_window_usage * 100.; + let context_window_usage = self.usage_info.context_window_usage.clamp(0.0, 1.0); + let context_usage_pct = context_window_usage * 100.; let context_usage_str = if context_window_breakdown_enabled && self.context_window_expanded { format!("{context_usage_pct:.2}%") @@ -540,7 +541,7 @@ impl ConversationUsageView { ) .with_child( ConstrainedBox::new(render_context_window_usage_icon( - self.usage_info.context_window_usage, + context_window_usage, theme, None, )) diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index b9140c01..5b84cc87 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -602,7 +602,7 @@ pub struct LLMPreferences { #[cfg(not(target_family = "wasm"))] openai_provider_routing: HashMap, /// Models fetched from the OpenAI-compatible /models endpoint at runtime. - /// Stored in memory only — not persisted to TOML. + /// Used as a short-lived fallback while the fetched list is persisted to settings. #[cfg(not(target_family = "wasm"))] fetched_openai_models: Vec, } @@ -670,10 +670,18 @@ impl LLMPreferences { AISettingsChangedEvent::BedrockEnabled { .. } | AISettingsChangedEvent::OpenAIEnabled { .. } | AISettingsChangedEvent::OpenAIBaseUrl { .. } + | AISettingsChangedEvent::OpenAIApiKey { .. } + | AISettingsChangedEvent::OpenAIModels { .. } + | AISettingsChangedEvent::OpenAIProviders { .. } ) { me.inject_bedrock_models(ctx); me.inject_openai_models(ctx); - if matches!(event, AISettingsChangedEvent::OpenAIEnabled { .. } | AISettingsChangedEvent::OpenAIBaseUrl { .. }) { + if matches!( + event, + AISettingsChangedEvent::OpenAIEnabled { .. } + | AISettingsChangedEvent::OpenAIBaseUrl { .. } + | AISettingsChangedEvent::OpenAIApiKey { .. } + ) { me.fetch_openai_models_from_endpoint(ctx); } // Safety: ensure the default model is still present in choices. @@ -961,11 +969,17 @@ impl LLMPreferences { return; } - // Models come exclusively from the in-memory /models endpoint fetch. let mut provider_entries: Vec<(String, String, Option, Vec)> = Vec::new(); - if !self.fetched_openai_models.is_empty() { + let configured_models = settings.openai_models.value().clone(); + let single_provider_models = if configured_models.is_empty() { + self.fetched_openai_models.clone() + } else { + configured_models + }; + + if !single_provider_models.is_empty() { let base_url = settings.openai_base_url.value().clone(); let api_key = { let key = settings.openai_api_key.value().clone(); @@ -980,9 +994,27 @@ impl LLMPreferences { } else { "LiteLLM".to_string() }; - provider_entries.push((name, base_url, api_key, self.fetched_openai_models.clone())); + provider_entries.push((name, base_url, api_key, single_provider_models)); } + provider_entries.extend( + settings + .openai_providers + .value() + .iter() + .filter_map(|provider| { + if provider.base_url.trim().is_empty() || provider.models.is_empty() { + return None; + } + Some(( + provider.name.clone(), + provider.base_url.clone(), + provider.api_key.clone(), + provider.models.clone(), + )) + }), + ); + if provider_entries.is_empty() { return; } @@ -990,20 +1022,21 @@ impl LLMPreferences { let mut total_injected = 0; let mut seen_model_ids: HashSet = HashSet::new(); for (provider_name, base_url, api_key, models) in provider_entries { - let client_config = OpenAIClientConfig { - base_url: base_url.clone(), - api_key: api_key.clone(), - model: None, // filled per-request from model_id - }; - for model in &models { if !seen_model_ids.insert(model.model_id.clone()) { continue; } // Register the routing entry + let client_config = OpenAIClientConfig { + base_url: base_url.clone(), + api_key: api_key.clone(), + model: None, // filled per-request from model_id + max_input_tokens: Some(openai_model_context_size(model)), + max_output_tokens: model.max_output_tokens, + }; self.openai_provider_routing - .insert(model.model_id.clone(), client_config.clone()); + .insert(model.model_id.clone(), client_config); let llm_info = LLMInfo { id: LLMId::from(model.model_id.as_str()), @@ -1027,7 +1060,7 @@ impl LLMPreferences { }, )]), discount_percentage: None, - context_window: LLMContextWindow::default(), + context_window: openai_model_context_window(model), }; self.models_by_feature .agent_mode @@ -1053,10 +1086,7 @@ impl LLMPreferences { if feature.choices.is_empty() { return; } - let default_exists = feature - .choices - .iter() - .any(|m| m.id == feature.default_id); + 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!( @@ -1143,17 +1173,36 @@ impl LLMPreferences { } }; + fn u32_from_any(value: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value[*key].as_u64()) + .and_then(|value| u32::try_from(value).ok()) + } + let models: Vec = body["data"] .as_array() - .unwrap_or(&vec![]) + .map(Vec::as_slice) + .unwrap_or_default() .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 max_input_tokens = u32_from_any( + m, + &["max_input_tokens", "input_token_limit", "max_prompt_tokens"], + ); + let context_size = + u32_from_any(m, &["max_model_len", "context_window", "token_size"]) + .or(max_input_tokens) + .unwrap_or(200_000); + let max_output_tokens = u32_from_any( + m, + &[ + "max_output_tokens", + "output_token_limit", + "max_completion_tokens", + "max_tokens", + ], + ); let display_name = id .split('/') @@ -1185,8 +1234,13 @@ impl LLMPreferences { Some(OpenAIModelConfig { model_id: id.to_string(), display_name, - vision_supported: m["supports_vision"].as_bool().unwrap_or(false), + vision_supported: m["supports_vision"] + .as_bool() + .or_else(|| m["vision_support"].as_bool()) + .unwrap_or(false), context_size, + max_input_tokens, + max_output_tokens, provider, }) }) @@ -1200,7 +1254,12 @@ impl LLMPreferences { }, |me, models, ctx| { if !models.is_empty() { - me.fetched_openai_models = models; + me.fetched_openai_models = models.clone(); + AISettings::handle(ctx).update(ctx, |settings, ctx| { + if let Err(err) = settings.openai_models.set_value(models, ctx) { + report_error!(err.context("Failed to persist fetched OpenAI models")); + } + }); me.inject_openai_models(ctx); ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); } @@ -2101,6 +2160,22 @@ fn get_new_agent_mode_choices( .collect() } +#[cfg(not(target_family = "wasm"))] +fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 { + model.max_input_tokens.unwrap_or(model.context_size) +} + +#[cfg(not(target_family = "wasm"))] +fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow { + let context_size = openai_model_context_size(model); + LLMContextWindow { + is_configurable: false, + min: context_size, + max: context_size, + default_max: context_size, + } +} + /// Builds synthetic [`LLMInfo`]s from the user's persisted custom endpoints. /// /// One entry per `CustomEndpointModel`. The display label is the **alias** when present, diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index bef4ec42..77858fa8 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -10,6 +10,7 @@ use crate::network::NetworkStatus; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; +use crate::settings::{OpenAIModelConfig, OpenAIProviderConfig}; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -240,6 +241,7 @@ fn custom_endpoint_usage_display_label_resolves_alias_name_and_generic_fallback( custom_llms: build_custom_llm_infos(&keys), custom_model_routers: Vec::new(), openai_provider_routing: HashMap::new(), + fetched_openai_models: Vec::new(), }; assert_eq!( @@ -260,6 +262,176 @@ fn custom_endpoint_usage_display_label_resolves_alias_name_and_generic_fallback( ); } +#[cfg(not(target_family = "wasm"))] +fn empty_llm_preferences_for_provider_tests() -> LLMPreferences { + LLMPreferences { + models_by_feature: ModelsByFeature::default(), + last_update: None, + base_llm_for_terminal_view: HashMap::new(), + custom_llms: Vec::new(), + custom_model_routers: Vec::new(), + openai_provider_routing: HashMap::new(), + fetched_openai_models: Vec::new(), + } +} + +#[cfg(not(target_family = "wasm"))] +fn openai_model( + model_id: &str, + display_name: &str, + context_size: u32, + max_input_tokens: Option, + max_output_tokens: Option, + vision_supported: bool, +) -> OpenAIModelConfig { + OpenAIModelConfig { + model_id: model_id.to_string(), + display_name: display_name.to_string(), + vision_supported, + context_size, + max_input_tokens, + max_output_tokens, + provider: Some("openai".to_string()), + } +} + +#[test] +#[cfg(not(target_family = "wasm"))] +fn openai_model_config_accepts_legacy_and_endpoint_field_names() { + let model: OpenAIModelConfig = toml::from_str( + r#" +model_id = "provider/custom-model" +display_name = "Custom Model" +vision_support = true +token_size = 123456 +max_input_tokens = 111111 +max_tokens = 8192 +provider = "openai" +"#, + ) + .expect("model config should parse"); + + assert_eq!(model.model_id, "provider/custom-model"); + assert!(model.vision_supported); + assert_eq!(model.context_size, 123_456); + assert_eq!(model.max_input_tokens, Some(111_111)); + assert_eq!(model.max_output_tokens, Some(8_192)); +} + +#[test] +#[cfg(not(target_family = "wasm"))] +fn inject_openai_models_uses_persisted_model_metadata_and_routing() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let mut preferences = empty_llm_preferences_for_provider_tests(); + + app.update(|ctx| { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + settings.openai_enabled.set_value(true, ctx).unwrap(); + settings + .openai_base_url + .set_value("https://litellm.example/v1".to_string(), ctx) + .unwrap(); + settings + .openai_api_key + .set_value("test-key".to_string(), ctx) + .unwrap(); + settings + .openai_models + .set_value( + vec![openai_model( + "provider/custom-model", + "Custom Model", + 200_000, + Some(128_000), + Some(8_192), + true, + )], + ctx, + ) + .unwrap(); + }); + + preferences.inject_openai_models(ctx); + + let model = preferences + .models_by_feature + .agent_mode + .choices + .iter() + .find(|model| model.id.as_str() == "provider/custom-model") + .expect("configured model should be injected"); + assert_eq!(model.provider, LLMProvider::LiteLLM); + assert_eq!(model.description.as_deref(), Some("LiteLLM")); + assert!(model.vision_supported); + assert_eq!(model.context_window.default_max, 128_000); + assert_eq!(model.context_window.max, 128_000); + + let client_config = preferences + .openai_client_config_for_model("provider/custom-model") + .expect("configured model should have routing"); + assert_eq!(client_config.base_url, "https://litellm.example/v1"); + assert_eq!(client_config.api_key.as_deref(), Some("test-key")); + assert_eq!(client_config.max_input_tokens, Some(128_000)); + assert_eq!(client_config.max_output_tokens, Some(8_192)); + }); + }); +} + +#[test] +#[cfg(not(target_family = "wasm"))] +fn inject_openai_models_uses_multi_provider_models() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let mut preferences = empty_llm_preferences_for_provider_tests(); + + app.update(|ctx| { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + settings.openai_enabled.set_value(true, ctx).unwrap(); + settings + .openai_providers + .set_value( + vec![OpenAIProviderConfig { + name: "Ollama".to_string(), + base_url: "http://localhost:11434/v1".to_string(), + api_key: None, + models: vec![openai_model( + "llama3.2", + "Llama 3.2", + 64_000, + None, + Some(4_096), + false, + )], + }], + ctx, + ) + .unwrap(); + }); + + preferences.inject_openai_models(ctx); + + let model = preferences + .models_by_feature + .agent_mode + .choices + .iter() + .find(|model| model.id.as_str() == "llama3.2") + .expect("provider model should be injected"); + assert_eq!(model.description.as_deref(), Some("Ollama")); + assert!(!model.vision_supported); + assert_eq!(model.context_window.default_max, 64_000); + + let client_config = preferences + .openai_client_config_for_model("llama3.2") + .expect("provider model should have routing"); + assert_eq!(client_config.base_url, "http://localhost:11434/v1"); + assert_eq!(client_config.max_input_tokens, Some(64_000)); + assert_eq!(client_config.max_output_tokens, Some(4_096)); + }); + }); +} + #[test] fn custom_llm_infos_skip_endpoints_with_empty_api_key() { let keys = ai::api_keys::ApiKeys { diff --git a/app/src/ai/openai/client.rs b/app/src/ai/openai/client.rs index f67147d4..125d39b9 100644 --- a/app/src/ai/openai/client.rs +++ b/app/src/ai/openai/client.rs @@ -9,6 +9,8 @@ pub struct OpenAIClientConfig { pub base_url: String, pub api_key: Option, pub model: Option, + pub max_input_tokens: Option, + pub max_output_tokens: Option, } pub struct OpenAIClient { diff --git a/app/src/ai/openai/response_translator.rs b/app/src/ai/openai/response_translator.rs index 6359f856..7c84272e 100644 --- a/app/src/ai/openai/response_translator.rs +++ b/app/src/ai/openai/response_translator.rs @@ -30,6 +30,7 @@ pub fn openai_stream_to_response_events( user_query: Option, messages_sent: Arc>>, model_id: String, + max_context_tokens: Option, _tool_result_archive: Vec, ) -> BoxStream<'static, Event> { use futures::StreamExt; @@ -269,7 +270,14 @@ pub fn openai_stream_to_response_events( } let cost = estimate_cost_cents(input_tokens as u32, output_tokens as u32, &model_id); - let finished_event = build_stream_finished(stop_reason, input_tokens, output_tokens, cost, &model_id); + let finished_event = build_stream_finished( + stop_reason, + input_tokens, + output_tokens, + cost, + &model_id, + max_context_tokens, + ); yield Ok(finished_event); log::info!("[openai] Stream finished: input_tokens={input_tokens}, output_tokens={output_tokens}"); @@ -409,6 +417,7 @@ fn build_stream_finished( output_tokens: i32, cost_in_cents: f32, model_id: &str, + max_context_tokens: Option, ) -> ResponseEvent { let total_tokens = (input_tokens + output_tokens) as u32; @@ -434,12 +443,14 @@ fn build_stream_finished( cost_in_cents, }]; - let max_context_tokens = context_window_for_model(model_id); + let max_context_tokens = + max_context_tokens.unwrap_or_else(|| context_window_for_model(model_id)); let context_usage = if max_context_tokens > 0 { input_tokens as f32 / max_context_tokens as f32 } else { 0.0 - }; + } + .clamp(0.0, 1.0); #[allow(deprecated)] let conversation_usage_metadata = Some(stream_finished::ConversationUsageMetadata { diff --git a/app/src/ai/openai/translator.rs b/app/src/ai/openai/translator.rs index 5b0fed1a..7b8047a3 100644 --- a/app/src/ai/openai/translator.rs +++ b/app/src/ai/openai/translator.rs @@ -10,6 +10,8 @@ use crate::ai::agent::api::ResponseStream; use crate::ai::bedrock::request_translator; use crate::ai::provider::types::{ConversationMessage, MessageContent, MessageRole}; +const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 64_000; + pub struct TranslatorRequest { pub config: OpenAIClientConfig, pub model_id: String, @@ -98,6 +100,10 @@ pub async fn execute( messages.extend(new_input_messages); } + for message in &mut messages { + message.truncate_tool_results_for_provider_request(); + } + sanitize_messages_for_openai(&mut messages); let system_prompt = request_translator::extract_system_prompt(request); @@ -112,11 +118,17 @@ pub async fn execute( let user_query_text = request_translator::extract_user_query_text(request); + let max_output_tokens = params + .config + .max_output_tokens + .unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS) + .min(i32::MAX as u32) as i32; + let request_body = build_openai_request( messages.clone(), system_prompt, tools, - 64000, + max_output_tokens, None, &model_id, ); @@ -140,6 +152,7 @@ pub async fn execute( user_query_text, params.messages_sent.clone(), model_id, + params.config.max_input_tokens, params.tool_result_archive, ); diff --git a/app/src/ai/provider/types.rs b/app/src/ai/provider/types.rs index 2becba45..7563a49d 100644 --- a/app/src/ai/provider/types.rs +++ b/app/src/ai/provider/types.rs @@ -1,11 +1,19 @@ use serde_json::Value as JsonValue; +pub const MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST: usize = 64_000; + #[derive(Clone, Debug)] pub struct ConversationMessage { pub role: MessageRole, pub content: MessageContent, } +impl ConversationMessage { + pub fn truncate_tool_results_for_provider_request(&mut self) { + truncate_tool_results_in_content(&mut self.content); + } +} + #[derive(Clone, Debug, PartialEq)] pub enum MessageRole { User, @@ -49,3 +57,71 @@ pub struct ToolDefinition { pub description: String, pub input_schema: JsonValue, } + +fn truncate_tool_results_in_content(content: &mut MessageContent) { + match content { + MessageContent::Text(_) | MessageContent::ToolUse { .. } => {} + MessageContent::ToolResult { content, .. } => truncate_tool_result_text(content), + MessageContent::MultiPart(parts) => { + for part in parts { + if let ContentPart::ToolResult { content, .. } = part { + truncate_tool_result_text(content); + } + } + } + } +} + +fn truncate_tool_result_text(content: &mut String) { + let char_count = content.chars().count(); + if char_count <= MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST { + return; + } + + let omitted_chars = char_count.saturating_sub(MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST); + let marker = format!("\n... [tool result truncated; omitted {omitted_chars} chars] ...\n"); + let marker_chars = marker.chars().count(); + let retained_chars = MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST.saturating_sub(marker_chars); + let head_chars = retained_chars / 2; + let tail_chars = retained_chars.saturating_sub(head_chars); + let head: String = content.chars().take(head_chars).collect(); + let tail: String = content + .chars() + .rev() + .take(tail_chars) + .collect::() + .chars() + .rev() + .collect(); + *content = format!("{head}{marker}{tail}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncates_large_tool_results_for_provider_request() { + let prefix = "start:"; + let suffix = ":end"; + let middle = "x".repeat(MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST + 1_000); + let mut message = ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "toolu_1".to_string(), + content: format!("{prefix}{middle}{suffix}"), + is_error: false, + }, + }; + + message.truncate_tool_results_for_provider_request(); + + let MessageContent::ToolResult { content, .. } = message.content else { + panic!("expected tool result"); + }; + assert!(content.len() <= MAX_TOOL_RESULT_CHARS_FOR_PROVIDER_REQUEST + 128); + assert!(content.starts_with(prefix)); + assert!(content.ends_with(suffix)); + assert!(content.contains("tool result truncated")); + } +} diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 94afea9d..77e7dc5c 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -733,8 +733,8 @@ impl schemars::JsonSchema for ToolbarCommandMap { std::borrow::Cow::Borrowed("ToolbarCommandMap") } - fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema { - gen.subschema_for::>() + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() } } @@ -847,12 +847,29 @@ pub struct OpenAIModelConfig { pub model_id: String, #[schemars(description = "Display name shown in the model picker.")] pub display_name: String, - #[serde(default)] + #[serde(default, alias = "vision_support", alias = "supports_vision")] #[schemars(description = "Whether the model supports image/vision input.")] pub vision_supported: bool, - #[serde(default = "default_context_size")] + #[serde( + default = "default_context_size", + alias = "token_size", + alias = "max_model_len", + alias = "context_window" + )] #[schemars(description = "Maximum context window size in tokens.")] pub context_size: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(description = "Optional maximum input tokens supported by this model.")] + pub max_input_tokens: Option, + #[serde( + default, + alias = "output_token_limit", + alias = "max_completion_tokens", + alias = "max_tokens", + skip_serializing_if = "Option::is_none" + )] + #[schemars(description = "Optional maximum output tokens to request from this model.")] + pub max_output_tokens: Option, #[serde(default)] #[schemars( description = "Optional provider hint (e.g. anthropic, openai, google) for icon display." diff --git a/app/src/terminal/input/agent.rs b/app/src/terminal/input/agent.rs index a91b9acd..e9ea63f2 100644 --- a/app/src/terminal/input/agent.rs +++ b/app/src/terminal/input/agent.rs @@ -8,7 +8,7 @@ use galaxyui::elements::{ PositioningAxis, Radius, SavePosition, Stack, Text, XAxisAnchor, YAxisAnchor, }; use galaxyui::presenter::ChildView; -use galaxyui::{AppContext, SingletonEntity as _}; +use galaxyui::{AppContext, EntityId, SingletonEntity as _}; use pathfinder_color::ColorU; use super::common::{ @@ -22,7 +22,10 @@ use crate::ai::blocklist::agent_view::shortcuts::{ }; use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState}; use crate::ai::blocklist::InputType; +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::execution_profiles::AIExecutionProfileAppExt; use crate::ai::harness_availability::HarnessAvailabilityModel; +use crate::ai::llms::LLMPreferences; use crate::appearance::Appearance; use crate::context_chips::spacing::{self}; use crate::editor::position_id_for_cursor; @@ -167,7 +170,9 @@ impl Input { .agent_view_state() .active_conversation_id() { - if let Some(status_bar) = render_session_status_bar(appearance, app, conv_id) { + if let Some(status_bar) = + render_session_status_bar(appearance, app, self.terminal_view_id, conv_id) + { column.add_child(status_bar); } } @@ -753,6 +758,7 @@ fn cache_hit_color(pct: f64, theme: &galaxy_core::ui::theme::GalaxyTheme) -> Col fn render_session_status_bar( appearance: &Appearance, app: &AppContext, + terminal_view_id: EntityId, conversation_id: crate::ai::agent::conversation::AIConversationId, ) -> Option> { let (cache_read, cache_write, cache_miss, cost_cents, context_usage, current_context) = @@ -778,12 +784,20 @@ fn render_session_status_bar( 0.0 }; - let max_context: u32 = if context_usage > 0.0 { - (current_context as f32 / context_usage).round() as u32 - } else { - 200_000 - }; - let context_pct = context_usage * 100.0; + let active_model = + LLMPreferences::as_ref(app).get_active_base_model(app, Some(terminal_view_id)); + let profile_context = AIExecutionProfilesModel::as_ref(app) + .active_profile(Some(terminal_view_id), app) + .data() + .context_window_display_value(app); + let model_max_context = active_model + .context_window + .default_max + .max(active_model.context_window.max); + let max_context = profile_context + .or((model_max_context > 0).then_some(model_max_context)) + .unwrap_or(200_000); + let context_pct = context_usage.clamp(0.0, 1.0) * 100.0; let theme = appearance.theme(); let font_family = appearance.ui_font_family(); diff --git a/script/build-and-deploy-hermes/src/index.tsx b/script/build-and-deploy-hermes/src/index.tsx index 6c7629b9..e9f9e196 100644 --- a/script/build-and-deploy-hermes/src/index.tsx +++ b/script/build-and-deploy-hermes/src/index.tsx @@ -19,7 +19,9 @@ if (!HERMES_PASS) { const BASE_URL = "https://client.wst.mini-games.tv"; const UPLOAD_KEY = "wst-data/ryan-share/galaxy/Galaxy.zip"; +const INSTALL_SCRIPT_KEY = "wst-data/ryan-share/galaxy/install-galaxy.sh"; const CONTENT_TYPE = "application/zip"; +const SCRIPT_CONTENT_TYPE = "text/x-shellscript"; // Workspace root is three levels up from script/build-and-deploy-hermes/src const WORKSPACE_ROOT = path.resolve(import.meta.dirname, "../../.."); @@ -271,6 +273,7 @@ function App() { { label: "Start multipart upload", status: "pending" }, { label: "Upload parts", status: "pending" }, { label: "Complete upload", status: "pending" }, + { label: "Upload install-galaxy.sh", status: "pending" }, ]); const updateStep = useCallback((index: number, update: Partial) => { @@ -422,6 +425,76 @@ function App() { appendLog(5, `Key: ${UPLOAD_KEY}`); updateStep(5, { status: "done" }); + // ─── Step 6: Upload install-galaxy.sh ──────────────────────────── + updateStep(6, { status: "running" }); + + const installScriptPath = path.join(WORKSPACE_ROOT, "script", "install-galaxy.sh"); + const scriptFileSize = statSync(installScriptPath).size; + const scriptFileHash = computeFileHash(installScriptPath); + + const scriptStartRes = await apiPost( + `${BASE_URL}/api/uploads/start`, + { + key: INSTALL_SCRIPT_KEY, + contentType: SCRIPT_CONTENT_TYPE, + fileSize: scriptFileSize, + fileHash: scriptFileHash, + }, + authHeaders(token) + ); + + const scriptCompletedParts: { partNumber: number; etag: string }[] = []; + let scriptBytesUploaded = 0; + + updateStep(6, { status: "running", progress: { bytes: 0, totalBytes: scriptFileSize } }); + + for (const part of scriptStartRes.urls) { + const prevBytes = scriptBytesUploaded; + const etag = await uploadPart( + part.url, + installScriptPath, + part.partNumber, + scriptStartRes.partSize, + scriptStartRes.totalParts, + (partBytes) => { + updateStep(6, { + status: "running", + progress: { bytes: prevBytes + partBytes, totalBytes: scriptFileSize }, + }); + } + ); + + const thisPartSize = part.partNumber === scriptStartRes.totalParts + ? scriptFileSize - (scriptStartRes.partSize * (scriptStartRes.totalParts - 1)) + : scriptStartRes.partSize; + scriptBytesUploaded += thisPartSize; + + scriptCompletedParts.push({ partNumber: part.partNumber, etag }); + + await apiPost( + `${BASE_URL}/api/uploads/part-complete`, + { + uploadId: scriptStartRes.uploadId, + partNumber: part.partNumber, + etag, + }, + authHeaders(token) + ); + } + + await apiPost( + `${BASE_URL}/api/uploads/complete`, + { + key: INSTALL_SCRIPT_KEY, + uploadId: scriptStartRes.uploadId, + parts: scriptCompletedParts.sort((a, b) => a.partNumber - b.partNumber), + }, + authHeaders(token) + ); + + appendLog(6, `Key: ${INSTALL_SCRIPT_KEY}`); + updateStep(6, { status: "done", detail: `${(scriptFileSize / 1024).toFixed(1)} KB`, progress: undefined }); + // Cleanup zip execSync(`rm -f "${zipPath}"`, { stdio: "pipe" }); } catch (err: any) { diff --git a/script/install-galaxy.sh b/script/install-galaxy.sh index e64f5670..45e20909 100755 --- a/script/install-galaxy.sh +++ b/script/install-galaxy.sh @@ -34,8 +34,6 @@ unzip -q "$TMP_DIR/Galaxy.zip" -d "$TMP_DIR" || fail "Failed to extract Galaxy.z rm -rf "$TMP_DIR/__MACOSX" find "$TMP_DIR/$APP_NAME" -name '.DS_Store' -delete 2>/dev/null || true find "$TMP_DIR/$APP_NAME" -name '._*' -delete 2>/dev/null || true -# Remove custom Icon file from bundle root (causes "unsealed contents" codesign error) -rm -f "$TMP_DIR/$APP_NAME/Icon"$'\r' "$TMP_DIR/$APP_NAME/Icon" 2>/dev/null || true if [[ ! -d "$TMP_DIR/$APP_NAME" ]]; then fail "$APP_NAME not found after extraction."