mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
community hardening
This commit is contained in:
@@ -181,6 +181,9 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+30
-14
@@ -2506,6 +2506,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||
dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -3866,7 +3875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3903,7 +3912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3913,6 +3922,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"html-escape",
|
||||
"imap",
|
||||
"lettre",
|
||||
"mailparse",
|
||||
@@ -3934,7 +3944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3961,7 +3971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3987,7 +3997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4015,7 +4025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4032,7 +4042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4068,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4087,7 +4097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4106,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4138,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4161,7 +4171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4180,7 +4190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7377,6 +7387,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -8802,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.24"
|
||||
version = "0.3.25"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -119,6 +119,9 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
|
||||
@@ -390,68 +390,109 @@ pub async fn get_agent_session(
|
||||
|
||||
match state.kernel.memory.get_session(entry.session_id) {
|
||||
Ok(Some(session)) => {
|
||||
let messages: Vec<serde_json::Value> = session
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let mut tools: Vec<serde_json::Value> = Vec::new();
|
||||
let content = match &m.content {
|
||||
openfang_types::message::MessageContent::Text(t) => t.clone(),
|
||||
openfang_types::message::MessageContent::Blocks(blocks) => {
|
||||
// Extract human-readable text and tool info from blocks
|
||||
let mut texts = Vec::new();
|
||||
for b in blocks {
|
||||
match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => {
|
||||
texts.push(text.clone());
|
||||
}
|
||||
openfang_types::message::ContentBlock::Image { .. } => {
|
||||
texts.push("[Image]".to_string());
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolUse {
|
||||
name, ..
|
||||
} => {
|
||||
tools.push(serde_json::json!({
|
||||
"name": name,
|
||||
"running": false,
|
||||
"expanded": false,
|
||||
}));
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolResult {
|
||||
content: result,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
// Attach result to the most recent tool without a result
|
||||
if let Some(last_tool) = tools.last_mut() {
|
||||
// Two-pass approach: ToolUse blocks live in Assistant messages while
|
||||
// ToolResult blocks arrive in subsequent User messages. Pass 1
|
||||
// collects all tool_use entries keyed by id; pass 2 attaches results.
|
||||
|
||||
// Pass 1: build messages and a lookup from tool_use_id → (msg_idx, tool_idx)
|
||||
let mut built_messages: Vec<serde_json::Value> = Vec::new();
|
||||
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for m in &session.messages {
|
||||
let mut tools: Vec<serde_json::Value> = Vec::new();
|
||||
let content = match &m.content {
|
||||
openfang_types::message::MessageContent::Text(t) => t.clone(),
|
||||
openfang_types::message::MessageContent::Blocks(blocks) => {
|
||||
let mut texts = Vec::new();
|
||||
for b in blocks {
|
||||
match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => {
|
||||
texts.push(text.clone());
|
||||
}
|
||||
openfang_types::message::ContentBlock::Image { .. } => {
|
||||
texts.push("[Image]".to_string());
|
||||
}
|
||||
openfang_types::message::ContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
let tool_idx = tools.len();
|
||||
tools.push(serde_json::json!({
|
||||
"name": name,
|
||||
"input": input,
|
||||
"running": false,
|
||||
"expanded": false,
|
||||
}));
|
||||
// Will be filled after this loop when we know msg_idx
|
||||
tool_use_index
|
||||
.insert(id.clone(), (usize::MAX, tool_idx));
|
||||
}
|
||||
// ToolResult blocks are handled in pass 2
|
||||
openfang_types::message::ContentBlock::ToolResult { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.join("\n")
|
||||
}
|
||||
};
|
||||
// Skip messages that are purely tool results (User role with only ToolResult blocks)
|
||||
if content.is_empty() && tools.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let msg_idx = built_messages.len();
|
||||
// Fix up the msg_idx for tool_use entries registered with sentinel
|
||||
for (_, (mi, _)) in tool_use_index.iter_mut() {
|
||||
if *mi == usize::MAX {
|
||||
*mi = msg_idx;
|
||||
}
|
||||
}
|
||||
let mut msg = serde_json::json!({
|
||||
"role": format!("{:?}", m.role),
|
||||
"content": content,
|
||||
});
|
||||
if !tools.is_empty() {
|
||||
msg["tools"] = serde_json::Value::Array(tools);
|
||||
}
|
||||
built_messages.push(msg);
|
||||
}
|
||||
|
||||
// Pass 2: walk messages again and attach ToolResult to the correct tool
|
||||
for m in &session.messages {
|
||||
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
if let openfang_types::message::ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content: result,
|
||||
is_error,
|
||||
..
|
||||
} = b
|
||||
{
|
||||
if let Some(&(msg_idx, tool_idx)) =
|
||||
tool_use_index.get(tool_use_id)
|
||||
{
|
||||
if let Some(msg) = built_messages.get_mut(msg_idx) {
|
||||
if let Some(tools_arr) =
|
||||
msg.get_mut("tools").and_then(|v| v.as_array_mut())
|
||||
{
|
||||
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
|
||||
let preview: String =
|
||||
result.chars().take(300).collect();
|
||||
last_tool["result"] =
|
||||
result.chars().take(2000).collect();
|
||||
tool_obj["result"] =
|
||||
serde_json::Value::String(preview);
|
||||
last_tool["is_error"] =
|
||||
tool_obj["is_error"] =
|
||||
serde_json::Value::Bool(*is_error);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.join("\n")
|
||||
}
|
||||
};
|
||||
// Skip messages that are purely tool results (User role with only ToolResult blocks)
|
||||
if content.is_empty() && tools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut msg = serde_json::json!({
|
||||
"role": format!("{:?}", m.role),
|
||||
"content": content,
|
||||
});
|
||||
if !tools.is_empty() {
|
||||
msg["tools"] = serde_json::Value::Array(tools);
|
||||
}
|
||||
Some(msg)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
let messages = built_messages;
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
@@ -540,6 +581,7 @@ pub async fn status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "running",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"agent_count": agent_count,
|
||||
"default_provider": state.kernel.config.default_model.provider,
|
||||
"default_model": state.kernel.config.default_model.model,
|
||||
@@ -5601,7 +5643,7 @@ pub async fn a2a_send_task(
|
||||
let task = openfang_runtime::a2a::A2aTask {
|
||||
id: task_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
status: openfang_runtime::a2a::A2aTaskStatus::Working,
|
||||
status: openfang_runtime::a2a::A2aTaskStatus::Working.into(),
|
||||
messages: vec![openfang_runtime::a2a::A2aMessage {
|
||||
role: "user".to_string(),
|
||||
parts: vec![openfang_runtime::a2a::A2aPart::Text {
|
||||
@@ -5710,10 +5752,10 @@ pub async fn a2a_list_external_agents(State(state): State<Arc<AppState>>) -> imp
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let items: Vec<serde_json::Value> = agents
|
||||
.iter()
|
||||
.map(|(url, card)| {
|
||||
.map(|(_, card)| {
|
||||
serde_json::json!({
|
||||
"name": card.name,
|
||||
"url": url,
|
||||
"url": card.url,
|
||||
"description": card.description,
|
||||
"skills": card.skills,
|
||||
"version": card.version,
|
||||
@@ -6943,15 +6985,14 @@ fn upsert_channel_config(
|
||||
}
|
||||
}
|
||||
FieldType::List => {
|
||||
// Always store list items as strings so that numeric IDs
|
||||
// (e.g. Discord guild snowflakes, Telegram user IDs) are
|
||||
// deserialized correctly into Vec<String> config fields.
|
||||
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()))
|
||||
})
|
||||
.map(|s| toml::Value::String(s.to_string()))
|
||||
.collect();
|
||||
toml::Value::Array(items)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -26,6 +26,7 @@ hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -453,16 +453,21 @@ async fn dispatch_message(
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
};
|
||||
desc
|
||||
}
|
||||
ChannelContent::File { ref url, ref filename } => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
format!("[User shared location: {lat}, {lon}]")
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]);
|
||||
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct TelegramAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_users: Vec<i64>,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -39,7 +39,7 @@ impl TelegramAdapter {
|
||||
///
|
||||
/// `token` is the raw bot token (read from env by the caller).
|
||||
/// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub fn new(token: String, allowed_users: Vec<i64>, poll_interval: Duration) -> Self {
|
||||
pub fn new(token: String, allowed_users: Vec<String>, poll_interval: Duration) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
@@ -371,7 +371,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
let msg = match parse_telegram_update(update, &allowed_users) {
|
||||
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client).await {
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -449,9 +449,34 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
|
||||
/// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable.
|
||||
/// Handles both `message` and `edited_message` update types.
|
||||
fn parse_telegram_update(
|
||||
/// Resolve a Telegram file_id to a download URL via the Bot API.
|
||||
async fn telegram_get_file_url(
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
file_id: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("https://api.telegram.org/bot{token}/getFile");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"file_id": file_id}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let file_path = body["result"]["file_path"].as_str()?;
|
||||
Some(format!(
|
||||
"https://api.telegram.org/file/bot{token}/{file_path}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn parse_telegram_update(
|
||||
update: &serde_json::Value,
|
||||
allowed_users: &[i64],
|
||||
allowed_users: &[String],
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
) -> Option<ChannelMessage> {
|
||||
let message = update
|
||||
.get("message")
|
||||
@@ -459,8 +484,9 @@ fn parse_telegram_update(
|
||||
let from = message.get("from")?;
|
||||
let user_id = from["id"].as_i64()?;
|
||||
|
||||
// Security: check allowed_users
|
||||
if !allowed_users.is_empty() && !allowed_users.contains(&user_id) {
|
||||
// Security: check allowed_users (compare as strings for consistency)
|
||||
let user_id_str = user_id.to_string();
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) {
|
||||
debug!("Telegram: ignoring message from unlisted user {user_id}");
|
||||
return None;
|
||||
}
|
||||
@@ -476,41 +502,81 @@ fn parse_telegram_update(
|
||||
|
||||
let chat_type = message["chat"]["type"].as_str().unwrap_or("private");
|
||||
let is_group = chat_type == "group" || chat_type == "supergroup";
|
||||
|
||||
let text = message["text"].as_str()?;
|
||||
let message_id = message["message_id"].as_i64().unwrap_or(0);
|
||||
let timestamp = message["date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
let content = if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities
|
||||
.iter()
|
||||
.any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0));
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
// Strip @botname from command (e.g. /agents@mybot -> agents)
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
// Determine content: text, photo, document, voice, or location
|
||||
let content = if let Some(text) = message["text"].as_str() {
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities.iter().any(|e| {
|
||||
e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)
|
||||
});
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else if let Some(photos) = message["photo"].as_array() {
|
||||
// Photos come as array of sizes; pick the largest (last)
|
||||
let file_id = photos
|
||||
.last()
|
||||
.and_then(|p| p["file_id"].as_str())
|
||||
.unwrap_or("");
|
||||
let caption = message["caption"].as_str().map(String::from);
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::Image { url, caption },
|
||||
None => ChannelContent::Text(format!(
|
||||
"[Photo received{}]",
|
||||
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else if message.get("document").is_some() {
|
||||
let file_id = message["document"]["file_id"].as_str().unwrap_or("");
|
||||
let filename = message["document"]["file_name"]
|
||||
.as_str()
|
||||
.unwrap_or("document")
|
||||
.to_string();
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::File { url, filename },
|
||||
None => ChannelContent::Text(format!("[Document received: {filename}]")),
|
||||
}
|
||||
} else if message.get("voice").is_some() {
|
||||
let file_id = message["voice"]["file_id"].as_str().unwrap_or("");
|
||||
let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32;
|
||||
match telegram_get_file_url(token, client, file_id).await {
|
||||
Some(url) => ChannelContent::Voice {
|
||||
url,
|
||||
duration_seconds: duration,
|
||||
},
|
||||
None => ChannelContent::Text(format!("[Voice message, {duration}s]")),
|
||||
}
|
||||
} else if message.get("location").is_some() {
|
||||
let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0);
|
||||
let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0);
|
||||
ChannelContent::Location { lat, lon }
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
// Unsupported message type (stickers, polls, etc.)
|
||||
return None;
|
||||
};
|
||||
|
||||
// Use chat_id as the platform_id (so responses go to the right chat)
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -594,8 +660,12 @@ fn sanitize_telegram_html(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_update() {
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_update() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123456,
|
||||
"message": {
|
||||
@@ -614,15 +684,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_command() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_command() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123457,
|
||||
"message": {
|
||||
@@ -645,7 +716,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -655,8 +727,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_users_filter() {
|
||||
#[tokio::test]
|
||||
async fn test_allowed_users_filter() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123458,
|
||||
"message": {
|
||||
@@ -674,21 +746,25 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_telegram_update(&update, &[]);
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
let msg = parse_telegram_update(&update, &[111, 222]);
|
||||
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
|
||||
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// Matching allowed_users = allow
|
||||
let msg = parse_telegram_update(&update, &[999]);
|
||||
let allowed: Vec<String> = vec!["999".to_string()];
|
||||
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_edited_message() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_edited_message() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123459,
|
||||
"edited_message": {
|
||||
@@ -708,7 +784,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
|
||||
@@ -729,8 +806,8 @@ mod tests {
|
||||
assert_eq!(b4, Duration::from_secs(60)); // stays at cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_botname() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_command_with_botname() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 100,
|
||||
"message": {
|
||||
@@ -743,7 +820,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -752,4 +830,22 @@ mod tests {
|
||||
other => panic!("Expected Command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_location() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 200,
|
||||
"message": {
|
||||
"message_id": 50,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"location": { "latitude": 51.5074, "longitude": -0.1278 }
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "venice",
|
||||
display: "Venice.ai",
|
||||
env_var: "VENICE_API_KEY",
|
||||
default_model: "venice-uncensored",
|
||||
needs_key: true,
|
||||
hint: "uncensored",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ai21",
|
||||
display: "AI21",
|
||||
@@ -2006,11 +2014,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize)
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or(&state.routing_models[t]);
|
||||
let display = if short.len() > 14 {
|
||||
&short[..14]
|
||||
} else {
|
||||
short
|
||||
};
|
||||
let display = openfang_types::truncate_str(short, 14);
|
||||
summary_spans.push(Span::styled(
|
||||
format!("{name}:{display}"),
|
||||
Style::default().fg(*c),
|
||||
|
||||
@@ -2479,7 +2479,7 @@ impl OpenFangKernel {
|
||||
.take(5)
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let truncated = if t.len() > 200 { &t[..200] } else { t };
|
||||
let truncated = openfang_types::truncate_str(t, 200);
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -5229,7 +5229,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
agents
|
||||
.iter()
|
||||
.map(|(url, card)| (card.name.clone(), url.clone()))
|
||||
.map(|(_, card)| (card.name.clone(), card.url.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -5242,7 +5242,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
agents
|
||||
.iter()
|
||||
.find(|(_, card)| card.name.to_lowercase() == name_lower)
|
||||
.map(|(url, _)| url.clone())
|
||||
.map(|(_, card)| card.url.clone())
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
|
||||
@@ -343,6 +343,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.40, 0.40);
|
||||
}
|
||||
|
||||
// ── Venice.ai ──────────────────────────────────────────────
|
||||
if model.contains("venice") {
|
||||
return (0.20, 0.90);
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
|
||||
@@ -87,8 +87,8 @@ pub struct A2aTask {
|
||||
/// Optional session identifier for conversation continuity.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Current task status.
|
||||
pub status: A2aTaskStatus,
|
||||
/// Current task status (accepts both string and object forms).
|
||||
pub status: A2aTaskStatusWrapper,
|
||||
/// Messages exchanged during the task.
|
||||
#[serde(default)]
|
||||
pub messages: Vec<A2aMessage>,
|
||||
@@ -115,6 +115,44 @@ pub enum A2aTaskStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Wrapper that accepts either a bare status string (`"completed"`)
|
||||
/// or the object form (`{"state": "completed", "message": null}`)
|
||||
/// used by some A2A implementations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum A2aTaskStatusWrapper {
|
||||
/// Object form: `{"state": "completed", "message": ...}`.
|
||||
Object {
|
||||
state: A2aTaskStatus,
|
||||
#[serde(default)]
|
||||
message: Option<serde_json::Value>,
|
||||
},
|
||||
/// Bare enum form: `"completed"`.
|
||||
Enum(A2aTaskStatus),
|
||||
}
|
||||
|
||||
impl A2aTaskStatusWrapper {
|
||||
/// Extract the underlying `A2aTaskStatus` regardless of encoding form.
|
||||
pub fn state(&self) -> &A2aTaskStatus {
|
||||
match self {
|
||||
Self::Object { state, .. } => state,
|
||||
Self::Enum(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn from(status: A2aTaskStatus) -> Self {
|
||||
Self::Enum(status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn eq(&self, other: &A2aTaskStatus) -> bool {
|
||||
self.state() == other
|
||||
}
|
||||
}
|
||||
|
||||
/// A2A message in a task conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aMessage {
|
||||
@@ -145,9 +183,23 @@ pub enum A2aPart {
|
||||
|
||||
/// A2A artifact produced by a task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct A2aArtifact {
|
||||
/// Artifact name.
|
||||
pub name: String,
|
||||
/// Artifact name (optional per spec).
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Human-readable description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Arbitrary metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Artifact index in the sequence.
|
||||
#[serde(default)]
|
||||
pub index: Option<u32>,
|
||||
/// Whether this is the last chunk of a streamed artifact.
|
||||
#[serde(default)]
|
||||
pub last_chunk: Option<bool>,
|
||||
/// Artifact content parts.
|
||||
pub parts: Vec<A2aPart>,
|
||||
}
|
||||
@@ -185,7 +237,7 @@ impl A2aTaskStore {
|
||||
.iter()
|
||||
.filter(|(_, t)| {
|
||||
matches!(
|
||||
t.status,
|
||||
t.status.state(),
|
||||
A2aTaskStatus::Completed | A2aTaskStatus::Failed | A2aTaskStatus::Cancelled
|
||||
)
|
||||
})
|
||||
@@ -211,7 +263,7 @@ impl A2aTaskStore {
|
||||
pub fn update_status(&self, task_id: &str, status: A2aTaskStatus) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.status = status;
|
||||
task.status = status.into();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -224,7 +276,7 @@ impl A2aTaskStore {
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(response);
|
||||
task.artifacts.extend(artifacts);
|
||||
task.status = A2aTaskStatus::Completed;
|
||||
task.status = A2aTaskStatus::Completed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +285,7 @@ impl A2aTaskStore {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(error_message);
|
||||
task.status = A2aTaskStatus::Failed;
|
||||
task.status = A2aTaskStatus::Failed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +542,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "task-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Submitted,
|
||||
status: A2aTaskStatus::Submitted.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -498,30 +550,71 @@ mod tests {
|
||||
|
||||
// Simulate progression
|
||||
let working = A2aTask {
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(working.status, A2aTaskStatus::Working);
|
||||
|
||||
let completed = A2aTask {
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(completed.status, A2aTaskStatus::Completed);
|
||||
|
||||
let cancelled = A2aTask {
|
||||
status: A2aTaskStatus::Cancelled,
|
||||
status: A2aTaskStatus::Cancelled.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(cancelled.status, A2aTaskStatus::Cancelled);
|
||||
|
||||
let failed = A2aTask {
|
||||
status: A2aTaskStatus::Failed,
|
||||
status: A2aTaskStatus::Failed.into(),
|
||||
..task
|
||||
};
|
||||
assert_eq!(failed.status, A2aTaskStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_task_status_wrapper_object_form() {
|
||||
// Test deserialization of the object form: {"state": "completed", "message": null}
|
||||
let json = r#"{"state":"completed","message":null}"#;
|
||||
let wrapper: A2aTaskStatusWrapper = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(wrapper, A2aTaskStatus::Completed);
|
||||
assert_eq!(wrapper.state(), &A2aTaskStatus::Completed);
|
||||
|
||||
// Test with a message payload
|
||||
let json_with_msg =
|
||||
r#"{"state":"working","message":{"text":"Processing..."}}"#;
|
||||
let wrapper2: A2aTaskStatusWrapper = serde_json::from_str(json_with_msg).unwrap();
|
||||
assert_eq!(wrapper2, A2aTaskStatus::Working);
|
||||
|
||||
// Test bare string form
|
||||
let json_bare = r#""completed""#;
|
||||
let wrapper3: A2aTaskStatusWrapper = serde_json::from_str(json_bare).unwrap();
|
||||
assert_eq!(wrapper3, A2aTaskStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_artifact_optional_fields() {
|
||||
// name is now optional — artifact with no name should deserialize
|
||||
let json = r#"{"parts":[{"type":"text","text":"hello"}]}"#;
|
||||
let artifact: A2aArtifact = serde_json::from_str(json).unwrap();
|
||||
assert!(artifact.name.is_none());
|
||||
assert!(artifact.description.is_none());
|
||||
assert!(artifact.metadata.is_none());
|
||||
assert!(artifact.index.is_none());
|
||||
assert!(artifact.last_chunk.is_none());
|
||||
assert_eq!(artifact.parts.len(), 1);
|
||||
|
||||
// Full artifact with all optional fields
|
||||
let json_full = r#"{"name":"output.txt","description":"The result","metadata":{"key":"val"},"index":0,"lastChunk":true,"parts":[]}"#;
|
||||
let full: A2aArtifact = serde_json::from_str(json_full).unwrap();
|
||||
assert_eq!(full.name.as_deref(), Some("output.txt"));
|
||||
assert_eq!(full.description.as_deref(), Some("The result"));
|
||||
assert_eq!(full.index, Some(0));
|
||||
assert_eq!(full.last_chunk, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_message_serde() {
|
||||
let msg = A2aMessage {
|
||||
@@ -554,7 +647,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -571,7 +664,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -599,7 +692,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-3".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -618,7 +711,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: format!("t-{i}"),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -630,7 +723,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ 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,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
@@ -194,6 +194,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "VOLCENGINE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"venice" => Some(ProviderDefaults {
|
||||
base_url: VENICE_BASE_URL,
|
||||
api_key_env: "VENICE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -345,7 +350,7 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
venice, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
@@ -382,6 +387,7 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"volcengine",
|
||||
"venice",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
@@ -480,7 +486,7 @@ mod tests {
|
||||
assert!(providers.contains(&"volcengine"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 30);
|
||||
assert_eq!(providers.len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Model catalog — registry of known models with metadata, pricing, and auth detection.
|
||||
//!
|
||||
//! Provides a comprehensive catalog of 130+ builtin models across 27 providers,
|
||||
//! Provides a comprehensive catalog of 130+ builtin models across 28 providers,
|
||||
//! with alias resolution, auth status detection, and pricing lookups.
|
||||
|
||||
use openfang_types::model_catalog::{
|
||||
@@ -9,7 +9,7 @@ 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,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
@@ -582,6 +582,16 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Venice.ai ────────────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "venice".into(),
|
||||
display_name: "Venice.ai".into(),
|
||||
api_key_env: "VENICE_API_KEY".into(),
|
||||
base_url: VENICE_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Chinese providers (5) ────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "qwen".into(),
|
||||
@@ -767,6 +777,8 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("codex", "codex/gpt-4.1"),
|
||||
("codex-4.1", "codex/gpt-4.1"),
|
||||
("codex-o4", "codex/o4-mini"),
|
||||
// Venice aliases
|
||||
("venice", "venice-uncensored"),
|
||||
// Claude Code aliases
|
||||
("claude-code", "claude-code/sonnet"),
|
||||
("claude-code-opus", "claude-code/opus"),
|
||||
@@ -3170,6 +3182,51 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-haiku".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Venice.ai (3)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "venice-uncensored".into(),
|
||||
display_name: "Venice Uncensored".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 32_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["venice".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "llama-3.3-70b".into(),
|
||||
display_name: "Llama 3.3 70B (Venice)".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-235b-a22b-instruct-2507".into(),
|
||||
display_name: "Qwen3 235B A22B (Venice)".into(),
|
||||
provider: "venice".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.90,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3186,7 +3243,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 34);
|
||||
assert_eq!(catalog.list_providers().len(), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,6 +4,26 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Deserialize a `Vec<String>` that tolerates both string and integer elements.
|
||||
///
|
||||
/// When channel configs are saved from the web dashboard, numeric IDs (e.g. Discord
|
||||
/// guild snowflakes, Telegram user IDs) are stored as TOML integers. This helper
|
||||
/// transparently converts integers back to strings so deserialization never fails.
|
||||
fn deserialize_string_or_int_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values: Vec<serde_json::Value> = serde::Deserialize::deserialize(deserializer)?;
|
||||
Ok(values
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s,
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// DM (direct message) policy for a channel.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1530,7 +1550,9 @@ pub struct TelegramConfig {
|
||||
/// Env var name holding the bot token (NOT the token itself).
|
||||
pub bot_token_env: String,
|
||||
/// Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub allowed_users: Vec<i64>,
|
||||
/// Accepts strings for consistency; numeric TOML integers are coerced to strings.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Polling interval in seconds.
|
||||
@@ -1560,9 +1582,10 @@ pub struct DiscordConfig {
|
||||
pub bot_token_env: String,
|
||||
/// Guild (server) IDs allowed to interact (empty = allow all).
|
||||
/// Accepts strings for consistency with other channel configs.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_guilds: Vec<String>,
|
||||
/// User IDs allowed to interact (empty = allow all).
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1595,6 +1618,7 @@ pub struct SlackConfig {
|
||||
/// Env var name holding the bot token (xoxb-) for REST API.
|
||||
pub bot_token_env: String,
|
||||
/// Channel IDs allowed to interact (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1631,6 +1655,7 @@ pub struct WhatsAppConfig {
|
||||
/// When set, outgoing messages are routed through the gateway instead of Cloud API.
|
||||
pub gateway_url_env: String,
|
||||
/// Allowed phone numbers (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1663,6 +1688,7 @@ pub struct SignalConfig {
|
||||
/// Registered phone number.
|
||||
pub phone_number: String,
|
||||
/// Allowed phone numbers (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1694,6 +1720,7 @@ pub struct MatrixConfig {
|
||||
/// Env var name holding the access token.
|
||||
pub access_token_env: String,
|
||||
/// Room IDs to listen in (empty = all joined rooms).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1734,8 +1761,10 @@ pub struct EmailConfig {
|
||||
/// Poll interval in seconds.
|
||||
pub poll_interval_secs: u64,
|
||||
/// IMAP folders to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub folders: Vec<String>,
|
||||
/// Only process emails from these senders (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_senders: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1773,6 +1802,7 @@ pub struct TeamsConfig {
|
||||
/// Port for the incoming webhook.
|
||||
pub webhook_port: u16,
|
||||
/// Allowed tenant IDs (empty = allow all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_tenants: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1803,6 +1833,7 @@ pub struct MattermostConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub token_env: String,
|
||||
/// Allowed channel IDs (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1836,6 +1867,7 @@ pub struct IrcConfig {
|
||||
/// Env var name holding the server password (optional).
|
||||
pub password_env: Option<String>,
|
||||
/// Channels to join (e.g., `["#openfang", "#general"]`).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub channels: Vec<String>,
|
||||
/// Use TLS (requires tokio-native-tls).
|
||||
pub use_tls: bool,
|
||||
@@ -1868,6 +1900,7 @@ pub struct GoogleChatConfig {
|
||||
/// Env var name holding the service account JSON key.
|
||||
pub service_account_env: String,
|
||||
/// Space IDs to listen in.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub space_ids: Vec<String>,
|
||||
/// Port for the incoming webhook.
|
||||
pub webhook_port: u16,
|
||||
@@ -1897,6 +1930,7 @@ pub struct TwitchConfig {
|
||||
/// Env var name holding the OAuth token.
|
||||
pub oauth_token_env: String,
|
||||
/// Twitch channels to join (without #).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub channels: Vec<String>,
|
||||
/// Bot nickname.
|
||||
pub nick: String,
|
||||
@@ -1930,6 +1964,7 @@ pub struct RocketChatConfig {
|
||||
/// User ID for the bot.
|
||||
pub user_id: String,
|
||||
/// Allowed channel IDs (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1962,6 +1997,7 @@ pub struct ZulipConfig {
|
||||
/// Env var name holding the API key.
|
||||
pub api_key_env: String,
|
||||
/// Streams to listen in.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub streams: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -1996,6 +2032,7 @@ pub struct XmppConfig {
|
||||
/// XMPP server port.
|
||||
pub port: u16,
|
||||
/// MUC rooms to join.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2120,6 +2157,7 @@ pub struct RedditConfig {
|
||||
/// Env var name holding the bot password.
|
||||
pub password_env: String,
|
||||
/// Subreddits to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub subreddits: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2263,6 +2301,7 @@ pub struct NextcloudConfig {
|
||||
/// Env var name holding the auth token.
|
||||
pub token_env: String,
|
||||
/// Room tokens to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2290,6 +2329,7 @@ pub struct GuildedConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub bot_token_env: String,
|
||||
/// Server IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub server_ids: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2318,6 +2358,7 @@ pub struct KeybaseConfig {
|
||||
/// Env var name holding the paper key.
|
||||
pub paperkey_env: String,
|
||||
/// Team names to listen in (empty = all DMs).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_teams: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2374,6 +2415,7 @@ pub struct NostrConfig {
|
||||
/// Env var name holding the private key (nsec or hex).
|
||||
pub private_key_env: String,
|
||||
/// Relay URLs to connect to.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub relays: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2400,6 +2442,7 @@ pub struct WebexConfig {
|
||||
/// Env var name holding the bot token.
|
||||
pub bot_token_env: String,
|
||||
/// Room IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_rooms: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2480,6 +2523,7 @@ pub struct TwistConfig {
|
||||
/// Workspace ID.
|
||||
pub workspace_id: String,
|
||||
/// Channel IDs to listen in (empty = all).
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub allowed_channels: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
@@ -2577,6 +2621,7 @@ pub struct DiscourseConfig {
|
||||
/// API username.
|
||||
pub api_username: String,
|
||||
/// Category slugs to monitor.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub categories: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
|
||||
@@ -59,6 +59,16 @@ mod tests {
|
||||
assert_eq!(truncate_str(s, 6), "hi\u{1F600}"); // after emoji
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_em_dash() {
|
||||
// Em dash (—) is 3 bytes (0xE2 0x80 0x94) — the exact char that caused
|
||||
// production panics in kernel.rs and session.rs (issue #104)
|
||||
let s = "Here is a summary — with details";
|
||||
assert_eq!(truncate_str(s, 19), "Here is a summary ");
|
||||
assert_eq!(truncate_str(s, 20), "Here is a summary ");
|
||||
assert_eq!(truncate_str(s, 21), "Here is a summary \u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_str_no_truncation() {
|
||||
assert_eq!(truncate_str("short", 100), "short");
|
||||
|
||||
@@ -28,6 +28,7 @@ pub const SAMBANOVA_BASE_URL: &str = "https://api.sambanova.ai/v1";
|
||||
pub const HUGGINGFACE_BASE_URL: &str = "https://api-inference.huggingface.co/v1";
|
||||
pub const XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
pub const REPLICATE_BASE_URL: &str = "https://api.replicate.com/v1";
|
||||
pub const VENICE_BASE_URL: &str = "https://api.venice.ai/api/v1";
|
||||
|
||||
// ── GitHub Copilot ──────────────────────────────────────────────
|
||||
pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
||||
|
||||
@@ -110,6 +110,11 @@ install() {
|
||||
tar xzf "$ARCHIVE" -C "$INSTALL_DIR"
|
||||
chmod +x "$INSTALL_DIR/openfang"
|
||||
|
||||
# Ad-hoc codesign on macOS (prevents SIGKILL on Apple Silicon)
|
||||
if [ "$OS" = "darwin" ] && command -v codesign &>/dev/null; then
|
||||
codesign --force --sign - "$INSTALL_DIR/openfang" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Add to PATH
|
||||
SHELL_RC=""
|
||||
case "${SHELL:-}" in
|
||||
|
||||
Reference in New Issue
Block a user