From eafeb6a0124ec905fc0ae87bc1ef64aa7b1e1027 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Thu, 5 Mar 2026 15:27:10 +0300 Subject: [PATCH] bugfix batch --- Cargo.lock | 28 +++++------ Cargo.toml | 2 +- crates/openfang-api/src/routes.rs | 48 ++++-------------- crates/openfang-kernel/src/kernel.rs | 53 ++++++++++++++++++++ crates/openfang-runtime/src/kernel_handle.rs | 15 ++++++ crates/openfang-runtime/src/model_catalog.rs | 28 +++++++++-- crates/openfang-runtime/src/tool_runner.rs | 39 +++++++++++--- 7 files changed, 147 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 907d21e6..88111003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "axum", @@ -3903,7 +3903,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "axum", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.18" +version = "0.3.19" dependencies = [ "clap", "clap_complete", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.18" +version = "0.3.19" dependencies = [ "axum", "open", @@ -3987,7 +3987,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.18" +version = "0.3.19" dependencies = [ "aes-gcm", "argon2", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.18" +version = "0.3.19" dependencies = [ "chrono", "dashmap", @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "chrono", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "chrono", @@ -4087,7 +4087,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.18" +version = "0.3.19" dependencies = [ "chrono", "dirs 6.0.0", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.18" +version = "0.3.19" dependencies = [ "anyhow", "async-trait", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.18" +version = "0.3.19" dependencies = [ "chrono", "hex", @@ -4161,7 +4161,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "chrono", @@ -4180,7 +4180,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.18" +version = "0.3.19" dependencies = [ "async-trait", "chrono", @@ -8802,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.18" +version = "0.3.19" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index d94b7f42..eb6cc39f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.3.19" +version = "0.3.20" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index ac886faf..bcf5c6ca 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6333,21 +6333,6 @@ pub async fn set_provider_key( Path(name): Path, Json(body): Json, ) -> impl IntoResponse { - // Validate provider name against known list - { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - if catalog.get_provider(&name).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - } - let key = match body["key"].as_str() { Some(k) if !k.trim().is_empty() => k.trim().to_string(), _ => { @@ -6358,6 +6343,7 @@ pub async fn set_provider_key( } }; + // Look up env var from catalog; for unknown/custom providers derive one. let env_var = { let catalog = state .kernel @@ -6367,16 +6353,15 @@ pub async fn set_provider_key( catalog .get_provider(&name) .map(|p| p.api_key_env.clone()) - .unwrap_or_default() + .unwrap_or_else(|| { + // Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY + format!( + "{}_API_KEY", + name.to_uppercase().replace('-', "_") + ) + }) }; - if env_var.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provider does not require an API key"})), - ); - } - // Write to secrets.env file let secrets_path = state.kernel.config.home_dir.join("secrets.env"); if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) { @@ -6563,22 +6548,7 @@ pub async fn set_provider_url( Path(name): Path, Json(body): Json, ) -> impl IntoResponse { - // Validate provider exists - let provider_exists = { - let catalog = state - .kernel - .model_catalog - .read() - .unwrap_or_else(|e| e.into_inner()); - catalog.get_provider(&name).is_some() - }; - if !provider_exists { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), - ); - } - + // Accept any provider name — custom providers are supported via OpenAI-compatible format. let base_url = match body["base_url"].as_str() { Some(u) if !u.trim().is_empty() => u.trim().to_string(), _ => { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 966ec007..e6a0d869 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -5274,6 +5274,59 @@ impl KernelHandle for OpenFangKernel { Ok(format!("Message sent to {} via {}", recipient, channel)) } + async fn send_channel_media( + &self, + channel: &str, + recipient: &str, + media_type: &str, + media_url: &str, + caption: Option<&str>, + filename: Option<&str>, + ) -> Result { + let adapter = self + .channel_adapters + .get(channel) + .ok_or_else(|| { + let available: Vec = self + .channel_adapters + .iter() + .map(|e| e.key().clone()) + .collect(); + format!( + "Channel '{}' not found. Available channels: {:?}", + channel, available + ) + })? + .clone(); + + let user = openfang_channels::types::ChannelUser { + platform_id: recipient.to_string(), + display_name: recipient.to_string(), + openfang_user: None, + }; + + let content = match media_type { + "image" => openfang_channels::types::ChannelContent::Image { + url: media_url.to_string(), + caption: caption.map(|s| s.to_string()), + }, + "file" => openfang_channels::types::ChannelContent::File { + url: media_url.to_string(), + filename: filename.unwrap_or("file").to_string(), + }, + _ => { + return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'.")); + } + }; + + adapter + .send(&user, content) + .await + .map_err(|e| format!("Channel media send failed: {e}"))?; + + Ok(format!("{} sent to {} via {}", media_type, recipient, channel)) + } + async fn spawn_agent_checked( &self, manifest_toml: &str, diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index a479750b..1bb2e76e 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -194,6 +194,21 @@ pub trait KernelHandle: Send + Sync { Err("Channel send not available".to_string()) } + /// Send media content (image/file) to a user on a named channel adapter. + /// `media_type` is "image" or "file", `media_url` is the URL, `caption` is optional text. + async fn send_channel_media( + &self, + channel: &str, + recipient: &str, + media_type: &str, + media_url: &str, + caption: Option<&str>, + filename: Option<&str>, + ) -> Result { + let _ = (channel, recipient, media_type, media_url, caption, filename); + Err("Channel media send not available".to_string()) + } + /// Spawn an agent with capability inheritance enforcement. /// `parent_caps` are the parent's granted capabilities. The kernel MUST verify /// that every capability in the child manifest is covered by `parent_caps`. diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index fd07fd03..8013fe20 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -162,14 +162,27 @@ impl ModelCatalog { p.base_url = url.to_string(); true } else { - false + // Custom provider — add a new entry so it appears in /api/providers + let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); + self.providers.push(ProviderInfo { + id: provider.to_string(), + display_name: provider.to_string(), + api_key_env: env_var, + base_url: url.to_string(), + key_required: true, + auth_status: AuthStatus::Missing, + model_count: 0, + }); + // Re-detect auth for the newly added provider + self.detect_auth(); + true } } /// Apply a batch of provider URL overrides from config. /// /// Each entry maps a provider ID to a custom base URL. - /// Unknown providers are silently skipped. + /// Unknown providers are automatically added as custom OpenAI-compatible entries. /// Providers with explicit URL overrides are marked as configured since /// the user intentionally set them up (e.g. local proxies, custom endpoints). pub fn apply_url_overrides(&mut self, overrides: &HashMap) { @@ -3361,8 +3374,15 @@ mod tests { #[test] fn test_set_provider_url_unknown() { let mut catalog = ModelCatalog::new(); - let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999"); - assert!(!updated); + let initial_count = catalog.list_providers().len(); + let updated = catalog.set_provider_url("my-custom-llm", "http://localhost:9999"); + // Unknown providers are now auto-registered as custom entries + assert!(updated); + assert_eq!(catalog.list_providers().len(), initial_count + 1); + assert_eq!( + catalog.get_provider("my-custom-llm").unwrap().base_url, + "http://localhost:9999" + ); } #[test] diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index c96e6407..7f3c5397 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -996,16 +996,19 @@ pub fn builtin_tool_definitions() -> Vec { // --- Channel send tool (proactive outbound messaging) --- ToolDefinition { name: "channel_send".to_string(), - description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(), + description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url or file_url to send an image or file instead of (or alongside) text.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" }, "recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" }, "subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" }, - "message": { "type": "string", "description": "The message body to send" } + "message": { "type": "string", "description": "The message body to send (required for text, optional caption for media)" }, + "image_url": { "type": "string", "description": "URL of an image to send (supported on Telegram, Discord, Slack)" }, + "file_url": { "type": "string", "description": "URL of a file to send as attachment" }, + "filename": { "type": "string", "description": "Filename for file attachments (defaults to 'file')" } }, - "required": ["channel", "recipient", "message"] + "required": ["channel", "recipient"] }), }, // --- Hand tools (curated autonomous capability packages) --- @@ -2119,24 +2122,44 @@ async fn tool_channel_send( .as_str() .ok_or("Missing 'recipient' parameter")? .trim(); - let message = input["message"] - .as_str() - .ok_or("Missing 'message' parameter")?; if recipient.is_empty() { return Err("Recipient cannot be empty".to_string()); } + + // Check for media content (image_url or file_url) + let image_url = input["image_url"].as_str().filter(|s| !s.is_empty()); + let file_url = input["file_url"].as_str().filter(|s| !s.is_empty()); + + if let Some(url) = image_url { + let caption = input["message"].as_str().filter(|s| !s.is_empty()); + return kh + .send_channel_media(&channel, recipient, "image", url, caption, None) + .await; + } + + if let Some(url) = file_url { + let caption = input["message"].as_str().filter(|s| !s.is_empty()); + let filename = input["filename"].as_str(); + return kh + .send_channel_media(&channel, recipient, "file", url, caption, filename) + .await; + } + + // Text-only message + let message = input["message"] + .as_str() + .ok_or("Missing 'message' parameter (required for text messages)")?; + if message.is_empty() { return Err("Message cannot be empty".to_string()); } // For email channels, validate email format and prepend subject let final_message = if channel == "email" { - // Basic email format validation if !recipient.contains('@') || !recipient.contains('.') { return Err(format!("Invalid email address: '{recipient}'")); } - // Prepend subject if provided if let Some(subject) = input["subject"].as_str() { if !subject.is_empty() { format!("Subject: {subject}\n\n{message}")