diff --git a/.github/workflows/release-beta-on-push.yml b/.github/workflows/release-beta-on-push.yml index 5d434ef0d..fad9ea049 100644 --- a/.github/workflows/release-beta-on-push.yml +++ b/.github/workflows/release-beta-on-push.yml @@ -150,7 +150,7 @@ jobs: cat SHA256SUMS - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: tag_name: ${{ needs.version.outputs.tag }} name: ${{ needs.version.outputs.tag }} diff --git a/.github/workflows/release-stable-manual.yml b/.github/workflows/release-stable-manual.yml index e06bda734..2ccf8fdf0 100644 --- a/.github/workflows/release-stable-manual.yml +++ b/.github/workflows/release-stable-manual.yml @@ -168,7 +168,7 @@ jobs: cat SHA256SUMS - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: tag_name: ${{ needs.validate.outputs.tag }} name: ${{ needs.validate.outputs.tag }} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index c25e020d8..f93a40a0e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -227,6 +227,7 @@ struct ChannelRuntimeContext { multimodal: crate::config::MultimodalConfig, hooks: Option>, non_cli_excluded_tools: Arc>, + model_routes: Arc>, } #[derive(Clone)] @@ -481,7 +482,7 @@ fn normalize_cached_channel_turns(turns: Vec) -> Vec { } fn supports_runtime_model_switch(channel_name: &str) -> bool { - matches!(channel_name, "telegram" | "discord") + matches!(channel_name, "telegram" | "discord" | "matrix") } fn parse_runtime_command(channel_name: &str, content: &str) -> Option { @@ -962,14 +963,29 @@ async fn create_resilient_provider_nonblocking( .context("failed to join provider initialization task")? } -fn build_models_help_response(current: &ChannelRouteSelection, workspace_dir: &Path) -> String { +fn build_models_help_response( + current: &ChannelRouteSelection, + workspace_dir: &Path, + model_routes: &[crate::config::ModelRouteConfig], +) -> String { let mut response = String::new(); let _ = writeln!( response, "Current provider: `{}`\nCurrent model: `{}`", current.provider, current.model ); - response.push_str("\nSwitch model with `/model `.\n"); + response.push_str("\nSwitch model with `/model ` or `/model `.\n"); + + if !model_routes.is_empty() { + response.push_str("\nConfigured model routes:\n"); + for route in model_routes { + let _ = writeln!( + response, + " `{}` → {} ({})", + route.hint, route.model, route.provider + ); + } + } let cached_models = load_cached_model_preview(workspace_dir, ¤t.provider); if cached_models.is_empty() { @@ -1042,7 +1058,6 @@ async fn handle_runtime_command_if_needed( if provider_name != current.provider { current.provider = provider_name.clone(); set_route_selection(ctx, &sender_key, current.clone()); - clear_sender_history(ctx, &sender_key); } format!( @@ -1063,20 +1078,27 @@ async fn handle_runtime_command_if_needed( } } ChannelRuntimeCommand::ShowModel => { - build_models_help_response(¤t, ctx.workspace_dir.as_path()) + build_models_help_response(¤t, ctx.workspace_dir.as_path(), &ctx.model_routes) } ChannelRuntimeCommand::SetModel(raw_model) => { let model = raw_model.trim().trim_matches('`').to_string(); if model.is_empty() { "Model ID cannot be empty. Use `/model `.".to_string() } else { - current.model = model.clone(); + // Resolve provider+model from model_routes (match by model name or hint) + if let Some(route) = ctx.model_routes.iter().find(|r| { + r.model.eq_ignore_ascii_case(&model) || r.hint.eq_ignore_ascii_case(&model) + }) { + current.provider = route.provider.clone(); + current.model = route.model.clone(); + } else { + current.model = model.clone(); + } set_route_selection(ctx, &sender_key, current.clone()); - clear_sender_history(ctx, &sender_key); format!( - "Model switched to `{model}` for provider `{}` in this sender session.", - current.provider + "Model switched to `{}` (provider: `{}`). Context preserved.", + current.model, current.provider ) } } @@ -3352,6 +3374,7 @@ pub async fn start_channels(config: Config) -> Result<()> { None }, non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()), + model_routes: Arc::new(config.model_routes.clone()), }); run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await; @@ -3565,6 +3588,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }; assert!(compact_sender_history(&ctx, &sender)); @@ -3614,6 +3638,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }; append_sender_turn(&ctx, &sender, ChatMessage::user("hello")); @@ -3666,6 +3691,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }; assert!(rollback_orphan_user_turn(&ctx, &sender, "pending")); @@ -4141,6 +4167,7 @@ BTC is currently around $65,000 based on latest tool output."# non_cli_excluded_tools: Arc::new(Vec::new()), multimodal: crate::config::MultimodalConfig::default(), hooks: None, + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4200,6 +4227,7 @@ BTC is currently around $65,000 based on latest tool output."# non_cli_excluded_tools: Arc::new(Vec::new()), multimodal: crate::config::MultimodalConfig::default(), hooks: None, + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4273,6 +4301,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4332,6 +4361,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4400,6 +4430,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4489,6 +4520,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4560,6 +4592,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4646,6 +4679,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4717,6 +4751,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4777,6 +4812,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -4948,6 +4984,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); let (tx, rx) = tokio::sync::mpsc::channel::(4); @@ -5028,6 +5065,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -5120,6 +5158,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -5194,6 +5233,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -5253,6 +5293,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -5769,6 +5810,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -5854,6 +5896,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -5939,6 +5982,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( @@ -6488,6 +6532,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); // Simulate a photo attachment message with [IMAGE:] marker. @@ -6554,6 +6599,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + model_routes: Arc::new(Vec::new()), }); process_channel_message( diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 3bd5f1f4f..890a912ec 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -107,15 +107,36 @@ impl std::fmt::Debug for ResolvedEmbeddingConfig { } } +/// Look up the provider-specific environment variable for common embedding providers, +/// so that `OPENAI_API_KEY` (etc.) takes precedence over the default-provider key +/// that the caller passes in. Returns `None` for unknown providers. +fn embedding_provider_env_key(provider: &str) -> Option { + let env_var = match provider.trim() { + "openai" => "OPENAI_API_KEY", + "openrouter" => "OPENROUTER_API_KEY", + "cohere" => "COHERE_API_KEY", + _ => return None, + }; + std::env::var(env_var) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + fn resolve_embedding_config( config: &MemoryConfig, embedding_routes: &[EmbeddingRouteConfig], api_key: Option<&str>, ) -> ResolvedEmbeddingConfig { - let fallback_api_key = api_key + let caller_api_key = api_key .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + // Prefer a provider-specific env var over the caller-supplied key, which + // may come from the default (chat) provider and differ from the embedding + // provider (issue #3083: gemini key leaking to openai embeddings endpoint). + let fallback_api_key = + embedding_provider_env_key(config.embedding_provider.trim()).or(caller_api_key); let fallback = ResolvedEmbeddingConfig { provider: config.embedding_provider.trim().to_string(), model: config.embedding_model.trim().to_string(), @@ -622,4 +643,45 @@ mod tests { } ); } + + // Regression guard for issue #3083: when default_provider is "gemini" + // (api_key = gemini key) but embedding_provider is "cohere", the + // embedding provider's own env var (COHERE_API_KEY) must take precedence + // over the caller-supplied key (which belongs to the default provider). + // + // Uses COHERE_API_KEY to avoid accidental collision with OPENAI_API_KEY + // that may be set in the developer environment. + #[test] + fn resolve_embedding_config_uses_embedding_provider_env_key_not_default_provider_key() { + // COHERE_API_KEY is almost certainly unset in normal dev environments. + let prev = std::env::var("COHERE_API_KEY").ok(); + std::env::set_var("COHERE_API_KEY", "cohere-from-env"); + + let cfg = MemoryConfig { + embedding_provider: "cohere".into(), + embedding_model: "embed-english-v3.0".into(), + embedding_dimensions: 1024, + ..MemoryConfig::default() + }; + + // Simulate: caller passes the Gemini (default_provider) api key. + let resolved = resolve_embedding_config(&cfg, &[], Some("gemini-key-must-not-be-used")); + + // Restore env. + match prev { + Some(v) => std::env::set_var("COHERE_API_KEY", v), + None => std::env::remove_var("COHERE_API_KEY"), + } + + assert_eq!( + resolved.api_key.as_deref(), + Some("cohere-from-env"), + "embedding api_key must come from COHERE_API_KEY env var, not from the default provider key" + ); + assert_ne!( + resolved.api_key.as_deref(), + Some("gemini-key-must-not-be-used"), + "default_provider key must not leak to the embedding provider" + ); + } }