mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c |
Generated
+14
-14
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user