Compare commits

...
5 Commits
Author SHA1 Message Date
jaberjaber23 50440e4047 bugfix batch 2026-03-05 02:13:48 +03:00
jaberjaber23 f45268aedc stress hardening 2026-03-05 01:21:32 +03:00
jaberjaber23 cc54e14114 bugfix batch 2026-03-05 00:25:25 +03:00
jaberjaber23 1037ef768d bugfix batch 2026-03-04 15:22:10 +03:00
jaberjaber23 c3dcf02e3c bugfix batch 2026-03-04 05:43:38 +03:00
23 changed files with 448 additions and 91 deletions
Generated
+14 -14
View File
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"axum",
@@ -3902,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"axum",
"open",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4031,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"anyhow",
"async-trait",
@@ -4137,7 +4137,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"chrono",
"hex",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"chrono",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.8"
version = "0.3.15"
dependencies = [
"async-trait",
"chrono",
@@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.8"
version = "0.3.15"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.11"
version = "0.3.16"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -1041,6 +1041,7 @@ pub async fn start_channel_bridge_with_config(
let adapter = Arc::new(DiscordAdapter::new(
token,
dc_config.allowed_guilds.clone(),
dc_config.allowed_users.clone(),
dc_config.intents,
));
adapters.push((adapter, dc_config.default_agent.clone()));
+1 -23
View File
@@ -52,30 +52,8 @@ pub async fn auth(
request: Request<Body>,
next: Next,
) -> Response<Body> {
// If no API key configured, restrict to loopback addresses only.
// If no API key configured, skip authentication entirely (open access).
if api_key.is_empty() {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
if !is_loopback {
tracing::warn!(
"Rejected non-localhost request: no API key configured. \
Set api_key in config.toml for remote access."
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
})
.to_string(),
))
.unwrap_or_default();
}
return next.run(request).await;
}
+2 -3
View File
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
return Some((entry.id, entry.name.clone()));
}
// 4. Fallback → first registered agent
let agents = state.kernel.registry.list();
agents.first().map(|e| (e.id, e.name.clone()))
// No match — return None so the caller returns a proper 404
None
}
// ── Message conversion ──────────────────────────────────────────────────────
+33 -3
View File
@@ -298,6 +298,14 @@ pub async fn send_message(
);
}
// Check agent exists before processing
if state.kernel.registry.get(agent_id).is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
// Resolve file attachments into image content blocks
if !req.attachments.is_empty() {
let image_blocks = resolve_attachments(&req.attachments);
@@ -337,8 +345,15 @@ pub async fn send_message(
}
Err(e) => {
tracing::warn!("send_message failed for agent {id}: {e}");
let status = if format!("{e}").contains("Agent not found") {
StatusCode::NOT_FOUND
} else if format!("{e}").contains("quota") || format!("{e}").contains("Quota") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
StatusCode::INTERNAL_SERVER_ERROR,
status,
Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})),
)
}
@@ -1169,6 +1184,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
fields: &[
ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false },
ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
ChannelField { key: "allowed_users", label: "Allowed User IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true },
],
@@ -5099,10 +5115,11 @@ pub async fn list_models(
true
})
.map(|m| {
// Custom models from unknown providers are assumed available
let available = catalog
.get_provider(&m.provider)
.map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing)
.unwrap_or(false);
.unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom);
serde_json::json!({
"id": m.id,
"display_name": m.display_name,
@@ -5176,7 +5193,7 @@ pub async fn get_model(
let available = catalog
.get_provider(&m.provider)
.map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing)
.unwrap_or(false);
.unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom);
(
StatusCode::OK,
Json(serde_json::json!({
@@ -6847,6 +6864,19 @@ fn upsert_channel_config(
toml::Value::String(v.clone())
}
}
FieldType::List => {
let items: Vec<toml::Value> = v
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<i64>()
.map(toml::Value::Integer)
.unwrap_or_else(|_| toml::Value::String(s.to_string()))
})
.collect();
toml::Value::Array(items)
}
_ => toml::Value::String(v.clone()),
};
ch_table.insert(k.clone(), toml_val);
+5 -1
View File
@@ -1114,7 +1114,11 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
"Model unavailable. Use /model to see options.".to_string()
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
} else {
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
}
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
+1 -1
View File
@@ -2787,7 +2787,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Text type -->
<template x-if="setting.setting_type === 'text'">
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
</template>
</div>
</template>
+9 -2
View File
@@ -407,8 +407,15 @@ async fn dispatch_message(
}
}
GroupPolicy::MentionOnly => {
// Pass through — adapters should only forward mentioned messages.
// This is a hint for adapters, not enforced here.
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message.metadata.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
if !was_mentioned && !is_command {
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
return;
}
}
GroupPolicy::All => {}
}
+141 -14
View File
@@ -39,6 +39,7 @@ pub struct DiscordAdapter {
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,12 +52,18 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<String>, intents: u64) -> Self {
pub fn new(
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
intents: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
allowed_guilds,
allowed_users,
intents,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
@@ -147,6 +154,7 @@ impl ChannelAdapter for DiscordAdapter {
let token = self.token.clone();
let intents = self.intents;
let allowed_guilds = self.allowed_guilds.clone();
let allowed_users = self.allowed_users.clone();
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
@@ -307,7 +315,7 @@ impl ChannelAdapter for DiscordAdapter {
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds)
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users)
.await
{
debug!(
@@ -423,6 +431,7 @@ async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[String],
allowed_users: &[String],
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -439,6 +448,12 @@ async fn parse_discord_message(
return None;
}
// Filter by allowed users
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
debug!("Discord: ignoring message from unlisted user {author_id}");
return None;
}
// Filter by allowed guilds
if !allowed_guilds.is_empty() {
if let Some(guild_id) = d["guild_id"].as_str() {
@@ -486,6 +501,29 @@ async fn parse_discord_message(
ChannelContent::Text(content_text.to_string())
};
// Determine if this is a group message (guild_id present = server channel)
let is_group = d["guild_id"].as_str().is_some();
// Check if bot was @mentioned (for MentionOnly policy enforcement)
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
// Check Discord mentions array
let mentioned_in_array = d["mentions"]
.as_array()
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content =
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
};
let mut metadata = HashMap::new();
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
Some(ChannelMessage {
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
@@ -497,9 +535,9 @@ async fn parse_discord_message(
content,
target_agent: None,
timestamp,
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
})
}
@@ -523,7 +561,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -545,7 +583,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
assert!(msg.is_none());
}
@@ -565,7 +603,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
assert!(msg.is_none());
}
@@ -586,11 +624,11 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await;
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await;
assert!(msg.is_some());
}
@@ -609,7 +647,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -634,7 +672,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
assert!(msg.is_none());
}
@@ -653,7 +691,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -675,16 +713,105 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
);
}
#[tokio::test]
async fn test_parse_discord_allowed_users_filter() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Hello",
"author": {
"id": "user999",
"username": "bob",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
assert!(msg.is_some());
}
#[tokio::test]
async fn test_parse_discord_mention_detection() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
// Message with bot mentioned in mentions array
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Hey <@bot123> help me",
"mentions": [{"id": "bot123", "username": "openfang"}],
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
// Message without mention in group
let d2 = serde_json::json!({
"id": "msg2",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Just chatting",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
#[tokio::test]
async fn test_parse_discord_dm_not_group() {
let bot_id = Arc::new(RwLock::new(None));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "dm-ch1",
"content": "Hello",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 37376);
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+9 -1
View File
@@ -2545,12 +2545,20 @@ decay_rate = 0.05
}
// Check for prompt injection issues in skill definitions
// Only flag Critical-severity warnings (Warning-level hits are expected
// in bundled skills that mention shell commands in educational context).
let skills = skill_reg.list();
let mut injection_warnings = 0;
for skill in &skills {
if let Some(ref prompt) = skill.manifest.prompt_context {
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
if !warnings.is_empty() {
let has_critical = warnings.iter().any(|w| {
matches!(
w.severity,
openfang_skills::verify::WarningSeverity::Critical
)
});
if has_critical {
injection_warnings += 1;
if !json {
ui::check_warn(&format!(
@@ -118,6 +118,7 @@ key = "elevenlabs_api_key"
label = "ElevenLabs API Key"
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
setting_type = "text"
env_var = "ELEVENLABS_API_KEY"
default = ""
# ─── Publishing settings ────────────────────────────────────────────────────
+11
View File
@@ -172,6 +172,10 @@ pub struct HandSetting {
pub default: String,
#[serde(default)]
pub options: Vec<HandSettingOption>,
/// Env var name to expose when a text-type setting has a value
/// (e.g. `ELEVENLABS_API_KEY` for an API key text field).
#[serde(default)]
pub env_var: Option<String>,
}
/// Result of resolving user-chosen settings against the schema.
@@ -227,6 +231,9 @@ pub fn resolve_settings(
HandSettingType::Text => {
if !chosen_value.is_empty() {
lines.push(format!("- {}: {}", setting.label, chosen_value));
if let Some(ref env) = setting.env_var {
env_vars.push(env.clone());
}
}
}
}
@@ -550,6 +557,7 @@ metrics = []
binary: None,
},
],
env_var: None,
}];
// User picks groq
@@ -583,6 +591,7 @@ metrics = []
binary: None,
},
],
env_var: None,
}];
// Empty config → uses default "auto"
@@ -604,6 +613,7 @@ metrics = []
setting_type: HandSettingType::Toggle,
default: "false".to_string(),
options: vec![],
env_var: None,
},
HandSetting {
key: "custom_model".to_string(),
@@ -612,6 +622,7 @@ metrics = []
setting_type: HandSettingType::Text,
default: String::new(),
options: vec![],
env_var: None,
},
];
+13 -7
View File
@@ -4652,17 +4652,23 @@ fn apply_budget_defaults(
/// This is a defense-in-depth fallback — models should ideally be in the catalog.
fn infer_provider_from_model(model: &str) -> Option<String> {
let lower = model.to_lowercase();
// Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5")
if let Some(prefix) = lower.split('/').next() {
// Check for explicit provider prefix with / or : delimiter
// (e.g., "minimax/MiniMax-M2.5" or "qwen:qwen-plus")
let (prefix, has_delim) = if let Some(idx) = lower.find('/') {
(&lower[..idx], true)
} else if let Some(idx) = lower.find(':') {
(&lower[..idx], true)
} else {
(lower.as_str(), false)
};
if has_delim {
match prefix {
"minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral"
| "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity"
| "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex"
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "moonshot"
| "openrouter" => {
if model.contains('/') {
return Some(prefix.to_string());
}
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot"
| "openrouter" | "volcengine" | "doubao" | "dashscope" => {
return Some(prefix.to_string());
}
_ => {}
}
+20
View File
@@ -376,6 +376,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── Zhipu / GLM ─────────────────────────────────────────────
if model.contains("glm-5") {
return (2.00, 8.00);
}
if model.contains("glm-4.7") {
return (1.50, 5.00);
}
if model.contains("glm-4-flash") {
return (0.10, 0.10);
}
@@ -391,6 +397,20 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
return (0.80, 0.80);
}
// ── Volcano Engine / Doubao ────────────────────────────────
if model.contains("doubao-seed-code") {
return (0.50, 1.00);
}
if model.contains("doubao") && model.contains("mini") {
return (0.10, 0.10);
}
if model.contains("doubao") && model.contains("lite") {
return (0.30, 0.60);
}
if model.contains("doubao") {
return (0.80, 2.00);
}
// ── Baidu ERNIE ─────────────────────────────────────────────
if model.contains("ernie") {
return (2.00, 6.00);
+6 -3
View File
@@ -57,9 +57,12 @@ const MAX_HISTORY_MESSAGES: usize = 20;
/// but the upstream API expects just `org/model`. This also handles special routers
/// like `openrouter/auto` → `auto`.
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
let prefix = format!("{}/", provider);
if model.starts_with(&prefix) {
model[prefix.len()..].to_string()
let slash_prefix = format!("{}/", provider);
let colon_prefix = format!("{}:", provider);
if model.starts_with(&slash_prefix) {
model[slash_prefix.len()..].to_string()
} else if model.starts_with(&colon_prefix) {
model[colon_prefix.len()..].to_string()
} else {
model.to_string()
}
@@ -415,12 +415,13 @@ impl LlmDriver for AnthropicDriver {
}
}
"content_block_delta" => {
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
let delta = &json["delta"];
match delta["type"].as_str().unwrap_or("") {
"text_delta" => {
if let Some(text) = delta["text"].as_str() {
if let Some(ContentBlockAccum::Text(ref mut t)) =
blocks.last_mut()
blocks.get_mut(block_idx)
{
t.push_str(text);
}
@@ -436,7 +437,7 @@ impl LlmDriver for AnthropicDriver {
if let Some(ContentBlockAccum::ToolUse {
ref mut input_json,
..
}) = blocks.last_mut()
}) = blocks.get_mut(block_idx)
{
input_json.push_str(partial);
}
@@ -450,7 +451,7 @@ impl LlmDriver for AnthropicDriver {
"thinking_delta" => {
if let Some(thinking) = delta["thinking"].as_str() {
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
blocks.last_mut()
blocks.get_mut(block_idx)
{
t.push_str(thinking);
}
@@ -460,11 +461,12 @@ impl LlmDriver for AnthropicDriver {
}
}
"content_block_stop" => {
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
if let Some(ContentBlockAccum::ToolUse {
id,
name,
input_json,
}) = blocks.last()
}) = blocks.get(block_idx)
{
let input: serde_json::Value =
serde_json::from_str(input_json).unwrap_or_default();
@@ -83,10 +83,17 @@ impl ClaudeCodeDriver {
}
/// JSON output from `claude -p --output-format json`.
///
/// The CLI may return the response text in different fields depending on
/// version: `result`, `content`, or `text`. We try all three.
#[derive(Debug, Deserialize)]
struct ClaudeJsonOutput {
result: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
usage: Option<ClaudeUsage>,
#[serde(default)]
#[allow(dead_code)]
@@ -157,7 +164,10 @@ impl LlmDriver for ClaudeCodeDriver {
// Try JSON parse first
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
let text = parsed.result.unwrap_or_default();
let text = parsed.result
.or(parsed.content)
.or(parsed.text)
.unwrap_or_default();
let usage = parsed.usage.unwrap_or_default();
return Ok(CompletionResponse {
content: vec![ContentBlock::Text { text: text.clone() }],
@@ -195,7 +205,8 @@ impl LlmDriver for ClaudeCodeDriver {
cmd.arg("-p")
.arg(&prompt)
.arg("--output-format")
.arg("stream-json");
.arg("stream-json")
.arg("--verbose");
if let Some(ref model) = model_flag {
cmd.arg("--model").arg(model);
@@ -232,7 +243,7 @@ impl LlmDriver for ClaudeCodeDriver {
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
Ok(event) => {
match event.r#type.as_str() {
"content" | "text" => {
"content" | "text" | "assistant" | "content_block_delta" => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
+20 -3
View File
@@ -17,8 +17,8 @@ use openfang_types::model_catalog::{
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -168,11 +168,26 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zai" => Some(ProviderDefaults {
base_url: ZAI_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zai_coding" => Some(ProviderDefaults {
base_url: ZAI_CODING_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"qianfan" | "baidu" => Some(ProviderDefaults {
base_url: QIANFAN_BASE_URL,
api_key_env: "QIANFAN_API_KEY",
key_required: true,
}),
"volcengine" | "doubao" => Some(ProviderDefaults {
base_url: VOLCENGINE_BASE_URL,
api_key_env: "VOLCENGINE_API_KEY",
key_required: true,
}),
_ => None,
}
}
@@ -360,6 +375,7 @@ pub fn known_providers() -> &'static [&'static str] {
"zhipu",
"zhipu_coding",
"qianfan",
"volcengine",
"codex",
"claude-code",
]
@@ -455,9 +471,10 @@ mod tests {
assert!(providers.contains(&"zhipu"));
assert!(providers.contains(&"zhipu_coding"));
assert!(providers.contains(&"qianfan"));
assert!(providers.contains(&"volcengine"));
assert!(providers.contains(&"codex"));
assert!(providers.contains(&"claude-code"));
assert_eq!(providers.len(), 29);
assert_eq!(providers.len(), 30);
}
#[test]
+130 -6
View File
@@ -9,8 +9,8 @@ use openfang_types::model_catalog::{
GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::collections::HashMap;
@@ -169,9 +169,18 @@ impl ModelCatalog {
///
/// Each entry maps a provider ID to a custom base URL.
/// Unknown providers are silently skipped.
/// 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<String, String>) {
for (provider, url) in overrides {
self.set_provider_url(provider, url);
if self.set_provider_url(provider, url) {
// Mark as configured so models from this provider show as available
if let Some(p) = self.providers.iter_mut().find(|p| p.id == *provider) {
if p.auth_status == AuthStatus::Missing {
p.auth_status = AuthStatus::Configured;
}
}
}
}
}
@@ -583,6 +592,24 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zai".into(),
display_name: "Z.AI".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZAI_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zai_coding".into(),
display_name: "Z.AI Coding".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZAI_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "moonshot".into(),
display_name: "Moonshot (Kimi)".into(),
@@ -601,6 +628,16 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── Volcano Engine (Doubao) ──────────────────────────────────
ProviderInfo {
id: "volcengine".into(),
display_name: "Volcano Engine (Doubao)".into(),
api_key_env: "VOLCENGINE_API_KEY".into(),
base_url: VOLCENGINE_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── AWS Bedrock ──────────────────────────────────────────────
ProviderInfo {
id: "bedrock".into(),
@@ -683,7 +720,7 @@ fn builtin_aliases() -> HashMap<String, String> {
("copilot-gpt4", "copilot/gpt-4"),
// Chinese model aliases
("qwen", "qwen-plus"),
("glm", "glm-4-plus"),
("glm", "glm-5-20250605"),
("ernie", "ernie-4.5-8k"),
("kimi", "moonshot-v1-128k"),
("minimax", "MiniMax-M2.5"),
@@ -2584,7 +2621,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu AI / GLM (4)
// Zhipu AI / GLM (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "glm-4-plus".into(),
@@ -2642,6 +2679,34 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "glm-5-20250605".into(),
display_name: "GLM-5".into(),
provider: "zhipu".into(),
tier: ModelTier::Frontier,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 2.00,
output_cost_per_m: 8.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["glm-5".into()],
},
ModelCatalogEntry {
id: "glm-4.7".into(),
display_name: "GLM-4.7".into(),
provider: "zhipu".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 1.50,
output_cost_per_m: 5.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu Coding / CodeGeeX (1)
// ══════════════════════════════════════════════════════════════
@@ -2750,6 +2815,65 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Volcano Engine / Doubao (4)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "doubao-seed-1-6-251015".into(),
display_name: "Doubao Seed 1.6 Pro".into(),
provider: "volcengine".into(),
tier: ModelTier::Smart,
context_window: 262_144,
max_output_tokens: 16_384,
input_cost_per_m: 0.80,
output_cost_per_m: 2.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao".into(), "doubao-pro".into()],
},
ModelCatalogEntry {
id: "doubao-seed-2-0-lite".into(),
display_name: "Doubao Seed 2.0 Lite".into(),
provider: "volcengine".into(),
tier: ModelTier::Balanced,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.30,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-lite".into()],
},
ModelCatalogEntry {
id: "doubao-seed-2-0-mini".into(),
display_name: "Doubao Seed 2.0 Mini".into(),
provider: "volcengine".into(),
tier: ModelTier::Fast,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.10,
output_cost_per_m: 0.10,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-mini".into()],
},
ModelCatalogEntry {
id: "doubao-seed-code".into(),
display_name: "Doubao Seed Code".into(),
provider: "volcengine".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.50,
output_cost_per_m: 1.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-code".into()],
},
// ══════════════════════════════════════════════════════════════
// AWS Bedrock (8)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -2956,7 +3080,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 30);
assert_eq!(catalog.list_providers().len(), 31);
}
#[test]
+2 -2
View File
@@ -41,7 +41,7 @@ fn check_taint_shell_exec(command: &str) -> Option<String> {
labels.insert(TaintLabel::ExternalNetwork);
let tainted = TaintedValue::new(command, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::shell_exec()) {
warn!(command = &command[..command.len().min(80)], %violation, "Shell taint check failed");
warn!(command = crate::str_utils::safe_truncate_str(command, 80), %violation, "Shell taint check failed");
return Some(violation.to_string());
}
}
@@ -68,7 +68,7 @@ fn check_taint_net_fetch(url: &str) -> Option<String> {
labels.insert(TaintLabel::Secret);
let tainted = TaintedValue::new(url, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::net_fetch()) {
warn!(url = &url[..url.len().min(80)], %violation, "Net fetch taint check failed");
warn!(url = crate::str_utils::safe_truncate_str(url, 80), %violation, "Net fetch taint check failed");
return Some(violation.to_string());
}
}
+4
View File
@@ -1561,6 +1561,9 @@ pub struct DiscordConfig {
/// Guild (server) IDs allowed to interact (empty = allow all).
/// Accepts strings for consistency with other channel configs.
pub allowed_guilds: Vec<String>,
/// User IDs allowed to interact (empty = allow all).
#[serde(default)]
pub allowed_users: Vec<String>,
/// Default agent name to route messages to.
pub default_agent: Option<String>,
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
@@ -1575,6 +1578,7 @@ impl Default for DiscordConfig {
Self {
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
allowed_guilds: vec![],
allowed_users: vec![],
default_agent: None,
intents: 37376,
overrides: ChannelOverrides::default(),
@@ -37,8 +37,12 @@ pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
/// Z.AI domain aliases (same API, different domain).
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
// ── AWS Bedrock ───────────────────────────────────────────────────
pub const BEDROCK_BASE_URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com";