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
This commit is contained in:
Generated
+1
-1
@@ -5653,7 +5653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "galaxy"
|
||||
version = "1.6.3"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"addr",
|
||||
"aha-reqwest-eventsource",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
+100
-25
@@ -602,7 +602,7 @@ pub struct LLMPreferences {
|
||||
#[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.
|
||||
/// Used as a short-lived fallback while the fetched list is persisted to settings.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fetched_openai_models: Vec<OpenAIModelConfig>,
|
||||
}
|
||||
@@ -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<String>, Vec<OpenAIModelConfig>)> =
|
||||
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<String> = 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<u32> {
|
||||
keys.iter()
|
||||
.find_map(|key| value[*key].as_u64())
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
let models: Vec<OpenAIModelConfig> = 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,
|
||||
|
||||
@@ -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<u32>,
|
||||
max_output_tokens: Option<u32>,
|
||||
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 {
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct OpenAIClientConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub max_input_tokens: Option<u32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct OpenAIClient {
|
||||
|
||||
@@ -30,6 +30,7 @@ pub fn openai_stream_to_response_events(
|
||||
user_query: Option<String>,
|
||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
model_id: String,
|
||||
max_context_tokens: Option<u32>,
|
||||
_tool_result_archive: Vec<ConversationMessage>,
|
||||
) -> 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<u32>,
|
||||
) -> 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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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::<String>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -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::<HashMap<String, String>>()
|
||||
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
generator.subschema_for::<HashMap<String, String>>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
#[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<u32>,
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Optional provider hint (e.g. anthropic, openai, google) for icon display."
|
||||
|
||||
@@ -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<Box<dyn Element>> {
|
||||
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();
|
||||
|
||||
@@ -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<Step>) => {
|
||||
@@ -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<StartUploadResponse>(
|
||||
`${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) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user