From 0ee3b6d6171dc1a171f44703a890f5cd4c21500e Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:44:12 +0100 Subject: [PATCH 1/3] fix(channels): resolve provider from model_routes on /model, preserve context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/model ` now auto-resolves provider from configured model_routes by matching model name or hint, fixing 404 when switching to models on different providers (e.g. `/model kimi-k2.5` with anthropic default) - Conversation history is no longer cleared on `/model` or `/models` — users can explicitly reset via `/new` - Matrix channel now supports `/model`, `/models`, and `/new` commands - `/model` (no args) lists configured model routes with hints Co-Authored-By: Claude Opus 4.6 --- src/channels/mod.rs | 64 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 820d7c9ae..23efb05f4 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 ) } } @@ -3351,6 +3373,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; @@ -3564,6 +3587,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)); @@ -3613,6 +3637,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")); @@ -3665,6 +3690,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")); @@ -4140,6 +4166,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( @@ -4199,6 +4226,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( @@ -4272,6 +4300,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( @@ -4331,6 +4360,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( @@ -4399,6 +4429,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( @@ -4488,6 +4519,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( @@ -4559,6 +4591,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( @@ -4645,6 +4678,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( @@ -4716,6 +4750,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( @@ -4776,6 +4811,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( @@ -4947,6 +4983,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); @@ -5027,6 +5064,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); @@ -5119,6 +5157,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); @@ -5193,6 +5232,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( @@ -5252,6 +5292,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( @@ -5768,6 +5809,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( @@ -5853,6 +5895,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( @@ -5938,6 +5981,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( @@ -6487,6 +6531,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. @@ -6553,6 +6598,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( From 71f013af49845878a74062d2ceea15326074cad3 Mon Sep 17 00:00:00 2001 From: Simian Astronaut 7 Date: Wed, 11 Mar 2026 15:39:00 -0400 Subject: [PATCH 2/3] chore: update action-gh-release to version 2.4.2 in release workflows --- .github/workflows/release-beta-on-push.yml | 2 +- .github/workflows/release-stable-manual.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 }} From 39353748fac3474cfe9c3786f2388654ee70a32a Mon Sep 17 00:00:00 2001 From: Aleksandr Prilipko <31770101+zverozabr@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:39:54 +0700 Subject: [PATCH 3/3] fix(memory): resolve embedding api_key from embedding_provider env var, not default_provider key (#3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When embedding_provider differs from default_provider (e.g. default=gemini, embedding=openai), the caller-supplied api_key belongs to the chat provider. Passing it to the embedding endpoint causes 401 Unauthorized (gemini key sent to api.openai.com/v1/embeddings). Add embedding_provider_env_key() which looks up OPENAI_API_KEY, OPENROUTER_API_KEY, or COHERE_API_KEY before falling back to the caller-supplied key. This matches the provider-specific env var resolution in providers/mod.rs without introducing cross-module coupling. Also add config_secrets_survive_save_load_roundtrip test: full save→load cycle with channel credentials (telegram, discord, slack bot_token, slack app_token) and gateway paired_tokens, verifying that enc2: values are correctly decrypted by Config::load_or_init(). Regression guard for issues #3173 and #3175. Closes #3083 Co-authored-by: ZeroClaw Bot Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Argenis --- src/memory/mod.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) 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" + ); + } }