fix community issues

This commit is contained in:
jaberjaber23
2026-03-12 16:42:39 +03:00
parent 14f4845170
commit b6b8b4ebe1
17 changed files with 1383 additions and 68 deletions
Generated
+14 -14
View File
@@ -3792,7 +3792,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"axum",
@@ -3829,7 +3829,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"axum",
@@ -3861,7 +3861,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"clap",
"clap_complete",
@@ -3888,7 +3888,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"axum",
"open",
@@ -3914,7 +3914,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"aes-gcm",
"argon2",
@@ -3942,7 +3942,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"chrono",
"dashmap",
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"chrono",
@@ -3996,7 +3996,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"chrono",
@@ -4015,7 +4015,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4034,7 +4034,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"anyhow",
"async-trait",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"chrono",
"hex",
@@ -4091,7 +4091,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"chrono",
@@ -4110,7 +4110,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.46"
version = "0.3.47"
dependencies = [
"async-trait",
"chrono",
@@ -8773,7 +8773,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.46"
version = "0.3.47"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.47"
version = "0.3.48"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+25 -17
View File
@@ -2645,9 +2645,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 1: Dependencies ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 1">
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
<div class="dep-card-header">
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
<span class="dep-card-title" x-text="req.label"></span>
<template x-if="req.install && req.install.estimated_time">
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
@@ -2690,24 +2690,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</ol>
</template>
<!-- API Key: numbered steps + signup link -->
<!-- API Key: input field + numbered steps + signup link -->
<template x-if="req.type === 'ApiKey' && req.install">
<div>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
<div style="margin-bottom:10px">
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">&check; Token entered</div>
</div>
<details style="margin-bottom:8px">
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
</div>
</div>
</div>
</template>
</template>
</details>
<div class="flex gap-2 mt-2">
<template x-if="req.install.signup_url">
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key &rarr;</a>
+68 -3
View File
@@ -36,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -110,11 +111,15 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
this.setupWizard = data;
@@ -283,7 +288,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -294,7 +302,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -306,6 +341,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -315,6 +354,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -332,11 +384,24 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
+50 -5
View File
@@ -76,14 +76,18 @@ impl SlackAdapter {
&self,
channel_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let chunks = split_message(text, SLACK_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"channel": channel_id,
"text": chunk,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -289,16 +293,40 @@ impl ChannelAdapter for SlackAdapter {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text).await?;
self.api_send_message(channel_id, &text, None).await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)")
self.api_send_message(channel_id, "(Unsupported content type)", None)
.await?;
}
}
Ok(())
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text, Some(thread_id))
.await?;
}
_ => {
self.api_send_message(
channel_id,
"(Unsupported content type)",
Some(thread_id),
)
.await?;
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
@@ -413,6 +441,23 @@ async fn parse_slack_event(
ChannelContent::Text(text.to_string())
};
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
// use their own `ts` so the reply will start a thread under the original.
let thread_id = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str())
.map(|s| s.to_string())
.or_else(|| Some(ts.to_string()));
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
if let Some(ref bid) = *bot_user_id.read().await {
let mention_tag = format!("<@{bid}>");
if text.contains(&mention_tag) {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
}
Some(ChannelMessage {
channel: ChannelType::Slack,
platform_message_id: ts.to_string(),
@@ -425,8 +470,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
+18 -7
View File
@@ -1480,15 +1480,26 @@ fn cmd_start(config: Option<PathBuf>) {
/// Returns `None` when the key is missing, empty, or whitespace-only —
/// meaning the daemon is running in public (unauthenticated) mode.
fn read_api_key() -> Option<String> {
// 1. Config file takes precedence
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?.trim();
if key.is_empty() {
None
} else {
Some(key.to_string())
if let Ok(text) = std::fs::read_to_string(config_path) {
if let Ok(table) = text.parse::<toml::Value>() {
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
}
}
// 2. Fall back to OPENFANG_API_KEY env var
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
return Some(key);
}
}
None
}
fn cmd_stop() {
+88 -6
View File
@@ -415,12 +415,7 @@ fn check_requirement(req: &HandRequirement) -> bool {
return true;
}
if req.check_value == "chromium" {
// Try common Chromium/Chrome binary names across platforms
return which_binary("chromium-browser")
|| which_binary("google-chrome")
|| which_binary("google-chrome-stable")
|| which_binary("chrome")
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
return check_chromium_available();
}
false
}
@@ -471,6 +466,93 @@ fn run_returns_python3(cmd: &str) -> bool {
}
}
/// Check if Chromium (or Chrome) is available anywhere on the system.
///
/// Checks in order:
/// 1. CHROME_PATH / CHROMIUM_PATH env vars
/// 2. Common binary names on PATH (chromium, chromium-browser, google-chrome, etc.)
/// 3. Well-known install paths (Windows Program Files, macOS Applications, Linux /usr)
/// 4. Playwright cache (~/.cache/ms-playwright/chromium-*)
fn check_chromium_available() -> bool {
// 1. Env vars
for var in &["CHROME_PATH", "CHROMIUM_PATH"] {
if let Ok(p) = std::env::var(var) {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return true;
}
}
}
// 2. Common binary names on PATH
let names = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"chrome",
];
for name in &names {
if which_binary(name) {
return true;
}
}
// 3. Well-known install paths
let known_paths: Vec<std::path::PathBuf> = if cfg!(windows) {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
let pf86 =
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
vec![
std::path::PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"),
]
} else if cfg!(target_os = "macos") {
vec![
std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
std::path::PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
} else {
vec![
std::path::PathBuf::from("/usr/bin/chromium"),
std::path::PathBuf::from("/usr/bin/chromium-browser"),
std::path::PathBuf::from("/usr/bin/google-chrome"),
std::path::PathBuf::from("/usr/bin/google-chrome-stable"),
std::path::PathBuf::from("/snap/bin/chromium"),
]
};
for p in &known_paths {
if p.exists() {
return true;
}
}
// 4. Playwright cache
if let Some(home) = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
{
let pw_cache = std::path::Path::new(&home).join(".cache/ms-playwright");
if pw_cache.is_dir() {
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("chromium-") && entry.path().is_dir() {
return true;
}
}
}
}
}
false
}
/// Check if a binary is on PATH (cross-platform).
fn which_binary(name: &str) -> bool {
let path_var = std::env::var("PATH").unwrap_or_default();
+12
View File
@@ -514,6 +514,18 @@ impl OpenFangKernel {
config.api_listen = listen;
}
// OPENFANG_API_KEY: env var sets the API authentication key when
// config.toml doesn't already have one. Config file takes precedence.
if config.api_key.trim().is_empty() {
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
info!("Using API key from OPENFANG_API_KEY environment variable");
config.api_key = key;
}
}
}
// Clamp configuration bounds to prevent zero-value or unbounded misconfigs
config.clamp_bounds();
+230
View File
@@ -1832,6 +1832,10 @@ pub async fn run_agent_loop_streaming(
/// 7. `<tool_call>{"name":"tool","arguments":{...}}</tool_call>` — Qwen3, issue #332
/// 8. Bare JSON `{"name":"tool","arguments":{...}}` objects (last resort, only if no tags found)
/// 9. `<function name="tool" parameters="{...}" />` — XML attribute style (Groq/Llama)
/// 10. `<|plugin|>...<|endofblock|>` — Qwen/ChatGLM thinking-model format
/// 11. `Action: tool\nAction Input: {"key":"value"}` — ReAct-style (LM Studio, GPT-OSS)
/// 12. `tool_name\n{"key":"value"}` — bare name + JSON on next line (Llama 4 Scout)
/// 13. `<tool_use>{"name":"tool","arguments":{...}}</tool_use>` — Llama 3.1+ variant
///
/// Validates tool names against available tools and returns synthetic `ToolCall` entries.
fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec<ToolCall> {
@@ -2214,6 +2218,121 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
}
}
// Pattern 10: <|plugin|>...<|endofblock|> (Qwen/ChatGLM thinking-model format)
search_from = 0;
while let Some(start) = text[search_from..].find("<|plugin|>") {
let abs_start = search_from + start;
let after_tag = abs_start + "<|plugin|>".len();
let close_tag = "<|endofblock|>";
let Some(close_offset) = text[after_tag..].find(close_tag) else {
search_from = after_tag;
continue;
};
let inner = text[after_tag..after_tag + close_offset].trim();
search_from = after_tag + close_offset + close_tag.len();
if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) {
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
info!(tool = tool_name.as_str(), "Recovered tool call from <|plugin|> block");
calls.push(ToolCall {
id: format!("recovered_{}", uuid::Uuid::new_v4()),
name: tool_name,
input,
});
}
}
}
// Pattern 11: Action: tool_name\nAction Input: {JSON} (ReAct-style, LM Studio / GPT-OSS)
{
let lines: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if let Some(tool_part) = line.strip_prefix("Action:").or_else(|| line.strip_prefix("action:")) {
let tool_name = tool_part.trim();
if tool_names.contains(&tool_name) {
// Look for "Action Input:" on the next line(s)
if i + 1 < lines.len() {
let next = lines[i + 1].trim();
if let Some(json_part) = next.strip_prefix("Action Input:").or_else(|| next.strip_prefix("action input:")).or_else(|| next.strip_prefix("action_input:")) {
let json_str = json_part.trim();
if let Ok(input) = serde_json::from_str::<serde_json::Value>(json_str) {
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
info!(tool = tool_name, "Recovered tool call from Action/Action Input pattern");
calls.push(ToolCall {
id: format!("recovered_{}", uuid::Uuid::new_v4()),
name: tool_name.to_string(),
input,
});
}
}
i += 2;
continue;
}
}
}
}
i += 1;
}
}
// Pattern 12: tool_name\n{"key":"value"} — bare name + JSON on next line (Llama 4 Scout)
{
let lines: Vec<&str> = text.lines().collect();
for i in 0..lines.len().saturating_sub(1) {
let name_line = lines[i].trim();
// Tool name must be a single word matching a known tool
if name_line.contains(' ') || name_line.contains('{') || name_line.is_empty() {
continue;
}
if !tool_names.contains(&name_line) {
continue;
}
// Next line must be valid JSON
let json_line = lines[i + 1].trim();
if !json_line.starts_with('{') {
continue;
}
if let Ok(input) = serde_json::from_str::<serde_json::Value>(json_line) {
if !calls.iter().any(|c| c.name == name_line && c.input == input) {
info!(tool = name_line, "Recovered tool call from name+JSON line pair");
calls.push(ToolCall {
id: format!("recovered_{}", uuid::Uuid::new_v4()),
name: name_line.to_string(),
input,
});
}
}
}
}
// Pattern 13: <tool_use>JSON</tool_use> (Llama 3.1+ variant)
search_from = 0;
while let Some(start) = text[search_from..].find("<tool_use>") {
let abs_start = search_from + start;
let after_tag = abs_start + "<tool_use>".len();
let Some(close_offset) = text[after_tag..].find("</tool_use>") else {
search_from = after_tag;
continue;
};
let inner = text[after_tag..after_tag + close_offset].trim();
search_from = after_tag + close_offset + "</tool_use>".len();
if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) {
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
info!(tool = tool_name.as_str(), "Recovered tool call from <tool_use> block");
calls.push(ToolCall {
id: format!("recovered_{}", uuid::Uuid::new_v4()),
name: tool_name,
input,
});
}
}
}
// Pattern 8: Bare JSON tool call objects in text (common Ollama fallback)
// Matches: {"name":"tool_name","arguments":{"key":"value"}} not already inside tags
// Only try this if no calls were found by tag-based patterns, to avoid false positives.
@@ -3680,6 +3799,117 @@ mod tests {
assert_eq!(calls[0].name, "shell_exec");
}
// --- Pattern 10: <|plugin|>...<|endofblock|> tests ---
#[test]
fn test_recover_plugin_block() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "<|plugin|>\n{\"name\": \"web_search\", \"arguments\": {\"query\": \"rust\"}}\n<|endofblock|>";
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "web_search");
assert_eq!(calls[0].input["query"], "rust");
}
#[test]
fn test_recover_plugin_block_unknown_tool() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "<|plugin|>\n{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}\n<|endofblock|>";
let calls = recover_text_tool_calls(text, &tools);
assert!(calls.is_empty());
}
// --- Pattern 11: Action/Action Input tests ---
#[test]
fn test_recover_action_input() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "Action: web_search\nAction Input: {\"query\": \"rust programming\"}";
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "web_search");
assert_eq!(calls[0].input["query"], "rust programming");
}
#[test]
fn test_recover_action_input_unknown_tool() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "Action: unknown_tool\nAction Input: {\"key\": \"value\"}";
let calls = recover_text_tool_calls(text, &tools);
assert!(calls.is_empty());
}
// --- Pattern 12: name + JSON on next line tests ---
#[test]
fn test_recover_name_json_nextline() {
let tools = vec![ToolDefinition {
name: "shell_exec".into(),
description: "Execute".into(),
input_schema: serde_json::json!({}),
}];
let text = "shell_exec\n{\"command\": \"ls -la\"}";
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell_exec");
assert_eq!(calls[0].input["command"], "ls -la");
}
#[test]
fn test_recover_name_json_nextline_unknown() {
let tools = vec![ToolDefinition {
name: "shell_exec".into(),
description: "Execute".into(),
input_schema: serde_json::json!({}),
}];
let text = "unknown_tool\n{\"command\": \"ls\"}";
let calls = recover_text_tool_calls(text, &tools);
assert!(calls.is_empty());
}
// --- Pattern 13: <tool_use> tests ---
#[test]
fn test_recover_tool_use_block() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "<tool_use>{\"name\": \"web_search\", \"arguments\": {\"query\": \"test\"}}</tool_use>";
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "web_search");
}
#[test]
fn test_recover_tool_use_block_unknown() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = "<tool_use>{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}</tool_use>";
let calls = recover_text_tool_calls(text, &tools);
assert!(calls.is_empty());
}
// --- Helper function tests ---
#[test]
@@ -73,6 +73,19 @@ struct GeminiContent {
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum GeminiPart {
/// Thinking/reasoning part emitted by Gemini 2.5+ thinking models.
/// JSON shape: `{ "text": "...", "thought": true }`.
/// Must be listed **before** `Text` so `serde(untagged)` matches it first.
Thought {
text: String,
thought: bool,
#[serde(
rename = "thoughtSignature",
default,
skip_serializing_if = "Option::is_none"
)]
thought_signature: Option<String>,
},
/// Text part, optionally carrying a thought signature.
Text {
text: String,
@@ -432,6 +445,14 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
input: function_call.args,
});
}
GeminiPart::Thought { text, .. } => {
// Gemini 2.5+ thinking parts — internal reasoning.
// Store as Thinking content block so the UI can
// optionally display it (like <think> blocks).
if !text.is_empty() {
content.push(ContentBlock::Thinking { thinking: text });
}
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
// Shouldn't normally appear in responses, ignore
}
@@ -726,6 +747,18 @@ impl LlmDriver for GeminiDriver {
thought_signature.clone(),
));
}
GeminiPart::Thought { ref text, .. } => {
// Gemini 2.5+ thinking chunk — emit as
// thinking delta so UIs can optionally
// show it; do NOT mix into text_content.
if !text.is_empty() {
let _ = tx
.send(StreamEvent::ThinkingDelta {
text: text.clone(),
})
.await;
}
}
GeminiPart::InlineData { .. }
| GeminiPart::FunctionResponse { .. } => {}
}
@@ -1603,4 +1636,95 @@ mod tests {
"thoughtSignature must be at part level, NOT inside functionCall"
);
}
#[test]
fn test_thought_part_deserialization() {
// Gemini 2.5+ thinking models emit { "text": "...", "thought": true }
let json = r#"{"text": "Let me think about this...", "thought": true}"#;
let part: GeminiPart = serde_json::from_str(json).unwrap();
match part {
GeminiPart::Thought { text, thought, .. } => {
assert_eq!(text, "Let me think about this...");
assert!(thought);
}
_ => panic!("Expected Thought variant, got {:?}", part),
}
}
#[test]
fn test_thought_part_with_signature() {
let json =
r#"{"text": "reasoning...", "thought": true, "thoughtSignature": "sig_abc123"}"#;
let part: GeminiPart = serde_json::from_str(json).unwrap();
match part {
GeminiPart::Thought {
text,
thought,
thought_signature,
} => {
assert_eq!(text, "reasoning...");
assert!(thought);
assert_eq!(thought_signature.as_deref(), Some("sig_abc123"));
}
_ => panic!("Expected Thought variant"),
}
}
#[test]
fn test_text_part_still_works_without_thought() {
// Regular text parts (no `thought` field) must still deserialize as Text
let json = r#"{"text": "Hello world"}"#;
let part: GeminiPart = serde_json::from_str(json).unwrap();
match part {
GeminiPart::Text { text, .. } => assert_eq!(text, "Hello world"),
// Thought variant with thought=false would also be acceptable
GeminiPart::Thought { text, thought, .. } => {
assert_eq!(text, "Hello world");
assert!(!thought);
}
_ => panic!("Expected Text or Thought variant"),
}
}
#[test]
fn test_thought_part_in_response_produces_thinking_block() {
let resp = GeminiResponse {
candidates: vec![GeminiCandidate {
content: Some(GeminiContent {
role: Some("model".to_string()),
parts: vec![
GeminiPart::Thought {
text: "Let me reason...".to_string(),
thought: true,
thought_signature: None,
},
GeminiPart::Text {
text: "Here is my answer.".to_string(),
thought_signature: None,
},
],
}),
finish_reason: Some("STOP".to_string()),
}],
usage_metadata: Some(GeminiUsageMetadata {
prompt_token_count: 10,
candidates_token_count: 20,
}),
};
let completion = convert_response(resp).unwrap();
// Should have a Thinking block and a Text block
assert_eq!(completion.content.len(), 2);
match &completion.content[0] {
ContentBlock::Thinking { thinking } => {
assert_eq!(thinking, "Let me reason...");
}
_ => panic!("Expected Thinking block, got {:?}", completion.content[0]),
}
match &completion.content[1] {
ContentBlock::Text { text, .. } => {
assert_eq!(text, "Here is my answer.");
}
_ => panic!("Expected Text block"),
}
}
}
+13 -1
View File
@@ -10,6 +10,7 @@ pub mod copilot;
pub mod fallback;
pub mod gemini;
pub mod openai;
pub mod qwen_code;
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
use openfang_types::model_catalog::{
@@ -309,6 +310,15 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
)));
}
// Qwen Code CLI — subprocess-based, uses Qwen OAuth (free tier)
if provider == "qwen-code" {
let cli_path = config.base_url.clone();
return Ok(Arc::new(qwen_code::QwenCodeDriver::new(
cli_path,
config.skip_permissions,
)));
}
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
// caches it, and refreshes when expired.
@@ -486,6 +496,7 @@ pub fn known_providers() -> &'static [&'static str] {
"venice",
"codex",
"claude-code",
"qwen-code",
]
}
@@ -587,7 +598,8 @@ mod tests {
assert!(providers.contains(&"chutes"));
assert!(providers.contains(&"codex"));
assert!(providers.contains(&"claude-code"));
assert_eq!(providers.len(), 34);
assert!(providers.contains(&"qwen-code"));
assert_eq!(providers.len(), 35);
}
#[test]
@@ -0,0 +1,600 @@
//! Qwen Code CLI backend driver.
//!
//! Spawns the `qwen` CLI (Qwen Code) as a subprocess in print mode (`-p`),
//! which is non-interactive and handles its own authentication.
//! This allows users with Qwen Code installed to use it as an LLM provider
//! without needing a separate API key (uses Qwen OAuth by default).
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
use async_trait::async_trait;
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
use serde::Deserialize;
use tokio::io::AsyncBufReadExt;
use tracing::{debug, warn};
/// Environment variable names to strip from the subprocess to prevent
/// leaking API keys from other providers.
const SENSITIVE_ENV_EXACT: &[&str] = &[
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GROQ_API_KEY",
"DEEPSEEK_API_KEY",
"MISTRAL_API_KEY",
"TOGETHER_API_KEY",
"FIREWORKS_API_KEY",
"OPENROUTER_API_KEY",
"PERPLEXITY_API_KEY",
"COHERE_API_KEY",
"AI21_API_KEY",
"CEREBRAS_API_KEY",
"SAMBANOVA_API_KEY",
"HUGGINGFACE_API_KEY",
"XAI_API_KEY",
"REPLICATE_API_TOKEN",
"BRAVE_API_KEY",
"TAVILY_API_KEY",
"ELEVENLABS_API_KEY",
];
/// Suffixes that indicate a secret — remove any env var ending with these
/// unless it starts with `QWEN_`.
const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"];
/// LLM driver that delegates to the Qwen Code CLI.
pub struct QwenCodeDriver {
cli_path: String,
skip_permissions: bool,
}
impl QwenCodeDriver {
/// Create a new Qwen Code driver.
///
/// `cli_path` overrides the CLI binary path; defaults to `"qwen"` on PATH.
/// `skip_permissions` adds `--yolo` to the spawned command so that the CLI
/// runs non-interactively (required for daemon mode).
pub fn new(cli_path: Option<String>, skip_permissions: bool) -> Self {
if skip_permissions {
warn!(
"Qwen Code driver: --yolo enabled. \
The CLI will not prompt for tool approvals. \
OpenFang's own capability/RBAC system enforces access control."
);
}
Self {
cli_path: cli_path
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "qwen".to_string()),
skip_permissions,
}
}
/// Detect if the Qwen Code CLI is available on PATH.
pub fn detect() -> Option<String> {
let output = std::process::Command::new("qwen")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Build the CLI arguments for a given request.
pub fn build_args(&self, prompt: &str, model: &str, streaming: bool) -> Vec<String> {
let mut args = vec!["-p".to_string(), prompt.to_string()];
args.push("--output-format".to_string());
if streaming {
args.push("stream-json".to_string());
args.push("--verbose".to_string());
} else {
args.push("json".to_string());
}
if self.skip_permissions {
args.push("--yolo".to_string());
}
let model_flag = Self::model_flag(model);
if let Some(ref m) = model_flag {
args.push("--model".to_string());
args.push(m.clone());
}
args
}
/// Build a text prompt from the completion request messages.
fn build_prompt(request: &CompletionRequest) -> String {
let mut parts = Vec::new();
if let Some(ref sys) = request.system {
parts.push(format!("[System]\n{sys}"));
}
for msg in &request.messages {
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
Role::System => "System",
};
let text = msg.content.text_content();
if !text.is_empty() {
parts.push(format!("[{role_label}]\n{text}"));
}
}
parts.join("\n\n")
}
/// Map a model ID like "qwen-code/qwen3-coder" to CLI --model flag value.
fn model_flag(model: &str) -> Option<String> {
let stripped = model.strip_prefix("qwen-code/").unwrap_or(model);
match stripped {
"qwen3-coder" | "coder" => Some("qwen3-coder".to_string()),
"qwen-coder-plus" | "coder-plus" => Some("qwen-coder-plus".to_string()),
"qwq-32b" | "qwq" => Some("qwq-32b".to_string()),
_ => Some(stripped.to_string()),
}
}
/// Apply security env filtering to a command.
fn apply_env_filter(cmd: &mut tokio::process::Command) {
for key in SENSITIVE_ENV_EXACT {
cmd.env_remove(key);
}
for (key, _) in std::env::vars() {
if key.starts_with("QWEN_") {
continue;
}
let upper = key.to_uppercase();
for suffix in SENSITIVE_SUFFIXES {
if upper.ends_with(suffix) {
cmd.env_remove(&key);
break;
}
}
}
}
}
/// JSON output from `qwen -p --output-format json`.
#[derive(Debug, Deserialize)]
struct QwenJsonOutput {
result: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
usage: Option<QwenUsage>,
#[serde(default)]
#[allow(dead_code)]
cost_usd: Option<f64>,
}
/// Usage stats from Qwen CLI JSON output.
#[derive(Debug, Deserialize, Default)]
struct QwenUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
/// Stream JSON event from `qwen -p --output-format stream-json`.
#[derive(Debug, Deserialize)]
struct QwenStreamEvent {
#[serde(default)]
r#type: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
result: Option<String>,
#[serde(default)]
usage: Option<QwenUsage>,
}
#[async_trait]
impl LlmDriver for QwenCodeDriver {
async fn complete(
&self,
request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
let prompt = Self::build_prompt(&request);
let args = self.build_args(&prompt, &request.model, false);
let mut cmd = tokio::process::Command::new(&self.cli_path);
for arg in &args {
cmd.arg(arg);
}
Self::apply_env_filter(&mut cmd);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI");
let output = cmd
.output()
.await
.map_err(|e| LlmError::Http(format!(
"Qwen Code CLI not found or failed to start ({}). \
Install: npm install -g @qwen-code/qwen-code && qwen auth",
e
)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() { &stderr } else { &stdout };
let code = output.status.code().unwrap_or(1);
let message = if detail.contains("not authenticated")
|| detail.contains("auth")
|| detail.contains("login")
|| detail.contains("credentials")
{
format!(
"Qwen Code CLI is not authenticated. Run: qwen auth\nDetail: {detail}"
)
} else {
format!("Qwen Code CLI exited with code {code}: {detail}")
};
return Err(LlmError::Api {
status: code as u16,
message,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(parsed) = serde_json::from_str::<QwenJsonOutput>(&stdout) {
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(),
provider_metadata: None,
}],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
});
}
let text = stdout.trim().to_string();
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text,
provider_metadata: None,
}],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: TokenUsage {
input_tokens: 0,
output_tokens: 0,
},
})
}
async fn stream(
&self,
request: CompletionRequest,
tx: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<CompletionResponse, LlmError> {
let prompt = Self::build_prompt(&request);
let args = self.build_args(&prompt, &request.model, true);
let mut cmd = tokio::process::Command::new(&self.cli_path);
for arg in &args {
cmd.arg(arg);
}
Self::apply_env_filter(&mut cmd);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI (streaming)");
let mut child = cmd
.spawn()
.map_err(|e| LlmError::Http(format!(
"Qwen Code CLI not found or failed to start ({}). \
Install: npm install -g @qwen-code/qwen-code && qwen auth",
e
)))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| LlmError::Http("No stdout from qwen CLI".to_string()))?;
let reader = tokio::io::BufReader::new(stdout);
let mut lines = reader.lines();
let mut full_text = String::new();
let mut final_usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
};
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<QwenStreamEvent>(&line) {
Ok(event) => match event.r#type.as_str() {
"content" | "text" | "assistant" | "content_block_delta" => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
.send(StreamEvent::TextDelta {
text: content.clone(),
})
.await;
}
}
"result" | "done" | "complete" => {
if let Some(ref result) = event.result {
if full_text.is_empty() {
full_text = result.clone();
let _ = tx
.send(StreamEvent::TextDelta {
text: result.clone(),
})
.await;
}
}
if let Some(usage) = event.usage {
final_usage = TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
};
}
}
_ => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
.send(StreamEvent::TextDelta {
text: content.clone(),
})
.await;
}
}
},
Err(e) => {
warn!(line = %line, error = %e, "Non-JSON line from Qwen CLI");
full_text.push_str(&line);
let _ = tx
.send(StreamEvent::TextDelta { text: line })
.await;
}
}
}
let status = child
.wait()
.await
.map_err(|e| LlmError::Http(format!("Qwen CLI wait failed: {e}")))?;
if !status.success() {
warn!(code = ?status.code(), "Qwen CLI exited with error");
}
let _ = tx
.send(StreamEvent::ContentComplete {
stop_reason: StopReason::EndTurn,
usage: final_usage,
})
.await;
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: full_text,
provider_metadata: None,
}],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: final_usage,
})
}
}
/// Check if the Qwen Code CLI is available.
pub fn qwen_code_available() -> bool {
QwenCodeDriver::detect().is_some() || qwen_credentials_exist()
}
/// Check if Qwen credentials exist.
fn qwen_credentials_exist() -> bool {
if let Some(home) = home_dir() {
let qwen_dir = home.join(".qwen");
qwen_dir.join("credentials.json").exists()
|| qwen_dir.join(".credentials.json").exists()
|| qwen_dir.join("auth.json").exists()
} else {
false
}
}
/// Cross-platform home directory.
fn home_dir() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE")
.ok()
.map(std::path::PathBuf::from)
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME")
.ok()
.map(std::path::PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_prompt_simple() {
use openfang_types::message::{Message, MessageContent};
let request = CompletionRequest {
model: "qwen-code/qwen3-coder".to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::text("Hello"),
}],
tools: vec![],
max_tokens: 1024,
temperature: 0.7,
system: Some("You are helpful.".to_string()),
thinking: None,
};
let prompt = QwenCodeDriver::build_prompt(&request);
assert!(prompt.contains("[System]"));
assert!(prompt.contains("You are helpful."));
assert!(prompt.contains("[User]"));
assert!(prompt.contains("Hello"));
}
#[test]
fn test_model_flag_mapping() {
assert_eq!(
QwenCodeDriver::model_flag("qwen-code/qwen3-coder"),
Some("qwen3-coder".to_string())
);
assert_eq!(
QwenCodeDriver::model_flag("qwen-code/qwen-coder-plus"),
Some("qwen-coder-plus".to_string())
);
assert_eq!(
QwenCodeDriver::model_flag("qwen-code/qwq-32b"),
Some("qwq-32b".to_string())
);
assert_eq!(
QwenCodeDriver::model_flag("coder"),
Some("qwen3-coder".to_string())
);
assert_eq!(
QwenCodeDriver::model_flag("custom-model"),
Some("custom-model".to_string())
);
}
#[test]
fn test_new_defaults_to_qwen() {
let driver = QwenCodeDriver::new(None, true);
assert_eq!(driver.cli_path, "qwen");
assert!(driver.skip_permissions);
}
#[test]
fn test_new_with_custom_path() {
let driver = QwenCodeDriver::new(Some("/usr/local/bin/qwen".to_string()), true);
assert_eq!(driver.cli_path, "/usr/local/bin/qwen");
}
#[test]
fn test_new_with_empty_path() {
let driver = QwenCodeDriver::new(Some(String::new()), true);
assert_eq!(driver.cli_path, "qwen");
}
#[test]
fn test_skip_permissions_disabled() {
let driver = QwenCodeDriver::new(None, false);
assert!(!driver.skip_permissions);
}
#[test]
fn test_sensitive_env_list_coverage() {
assert!(SENSITIVE_ENV_EXACT.contains(&"OPENAI_API_KEY"));
assert!(SENSITIVE_ENV_EXACT.contains(&"ANTHROPIC_API_KEY"));
assert!(SENSITIVE_ENV_EXACT.contains(&"GEMINI_API_KEY"));
assert!(SENSITIVE_ENV_EXACT.contains(&"GROQ_API_KEY"));
assert!(SENSITIVE_ENV_EXACT.contains(&"DEEPSEEK_API_KEY"));
}
#[test]
fn test_build_args_with_yolo() {
let driver = QwenCodeDriver::new(None, true);
let args = driver.build_args("test prompt", "qwen-code/qwen3-coder", false);
assert!(args.contains(&"--yolo".to_string()));
assert!(args.contains(&"json".to_string()));
assert!(args.contains(&"--model".to_string()));
}
#[test]
fn test_build_args_without_yolo() {
let driver = QwenCodeDriver::new(None, false);
let args = driver.build_args("test prompt", "qwen-code/qwen3-coder", false);
assert!(!args.contains(&"--yolo".to_string()));
}
#[test]
fn test_build_args_streaming() {
let driver = QwenCodeDriver::new(None, true);
let args = driver.build_args("test prompt", "qwen-code/qwen3-coder", true);
assert!(args.contains(&"stream-json".to_string()));
assert!(args.contains(&"--verbose".to_string()));
}
#[test]
fn test_json_output_deserialization() {
let json = r#"{"result":"Hello world","usage":{"input_tokens":10,"output_tokens":5}}"#;
let parsed: QwenJsonOutput = serde_json::from_str(json).unwrap();
assert_eq!(parsed.result.unwrap(), "Hello world");
assert_eq!(parsed.usage.unwrap().input_tokens, 10);
}
#[test]
fn test_json_output_content_field() {
let json = r#"{"content":"Hello from content field"}"#;
let parsed: QwenJsonOutput = serde_json::from_str(json).unwrap();
assert!(parsed.result.is_none());
assert_eq!(parsed.content.unwrap(), "Hello from content field");
}
#[test]
fn test_stream_event_deserialization() {
let json = r#"{"type":"content","content":"Hello"}"#;
let event: QwenStreamEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.r#type, "content");
assert_eq!(event.content.unwrap(), "Hello");
}
#[test]
fn test_stream_event_result() {
let json =
r#"{"type":"result","result":"Final answer","usage":{"input_tokens":20,"output_tokens":10}}"#;
let event: QwenStreamEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.r#type, "result");
assert_eq!(event.result.unwrap(), "Final answer");
assert_eq!(event.usage.unwrap().output_tokens, 10);
}
}
@@ -22,6 +22,7 @@ pub struct AgentInfo {
/// Handle to kernel operations, passed into the agent loop so agents
/// can interact with each other via tools.
#[allow(clippy::too_many_arguments)]
#[async_trait]
pub trait KernelHandle: Send + Sync {
/// Spawn a new agent from a TOML manifest string.
+1 -1
View File
@@ -5,7 +5,7 @@
/// Default User-Agent header sent with all outgoing HTTP requests.
/// Some LLM providers (e.g. Moonshot, Qwen) reject requests without one.
pub const USER_AGENT: &str = "openfang/0.3.47";
pub const USER_AGENT: &str = "openfang/0.3.48";
pub mod a2a;
pub mod agent_loop;
+16
View File
@@ -177,6 +177,18 @@ const FORMAT_PATTERNS: &[&str] = &[
"bad_request",
];
/// Empty response / truncated body patterns.
/// These indicate the provider returned an empty or truncated response body,
/// almost always due to transient load issues or dropped connections.
const EMPTY_RESPONSE_PATTERNS: &[&str] = &[
"eof while parsing a value",
"eof while parsing",
"provider returned empty response",
"empty response body",
"unexpected end of json",
"unexpected eof",
];
/// Overloaded patterns.
const OVERLOADED_PATTERNS: &[&str] = &[
"overloaded",
@@ -330,6 +342,10 @@ pub fn classify_error(message: &str, status: Option<u16>) -> ClassifiedError {
}
// 6. Format / bad request (before overloaded, since 400 is more specific)
// But first: empty response / truncated body is retryable, not a format error
if matches_any(&lower, EMPTY_RESPONSE_PATTERNS) {
return build(LlmErrorCategory::Overloaded);
}
if matches_any(&lower, FORMAT_PATTERNS) {
return build(LlmErrorCategory::Format);
}
+95 -1
View File
@@ -67,6 +67,15 @@ impl ModelCatalog {
};
continue;
}
if provider.id == "qwen-code" {
provider.auth_status =
if crate::drivers::qwen_code::qwen_code_available() {
AuthStatus::Configured
} else {
AuthStatus::Missing
};
continue;
}
if !provider.key_required {
provider.auth_status = AuthStatus::NotRequired;
@@ -755,6 +764,16 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::NotRequired,
model_count: 0,
},
// ── Qwen Code CLI ──────────────────────────────────────────
ProviderInfo {
id: "qwen-code".into(),
display_name: "Qwen Code".into(),
api_key_env: String::new(),
base_url: String::new(),
key_required: false,
auth_status: AuthStatus::NotRequired,
model_count: 0,
},
]
}
@@ -828,6 +847,11 @@ fn builtin_aliases() -> HashMap<String, String> {
("claude-code-opus", "claude-code/opus"),
("claude-code-sonnet", "claude-code/sonnet"),
("claude-code-haiku", "claude-code/haiku"),
// Qwen Code aliases
("qwen-code", "qwen-code/qwen3-coder"),
("qwen-coder", "qwen-code/qwen3-coder"),
("qwen-coder-plus", "qwen-code/qwen-coder-plus"),
("qwq", "qwen-code/qwq-32b"),
];
pairs
.into_iter()
@@ -3416,6 +3440,51 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec!["claude-code-haiku".into()],
},
// ══════════════════════════════════════════════════════════════
// Qwen Code CLI (3) — subprocess-based, free via Qwen OAuth
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "qwen-code/qwen-coder-plus".into(),
display_name: "Qwen Coder Plus (CLI)".into(),
provider: "qwen-code".into(),
tier: ModelTier::Frontier,
context_window: 131_072,
max_output_tokens: 65_536,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["qwen-coder-plus".into()],
},
ModelCatalogEntry {
id: "qwen-code/qwen3-coder".into(),
display_name: "Qwen3 Coder (CLI)".into(),
provider: "qwen-code".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 65_536,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["qwen-code".into(), "qwen-coder".into()],
},
ModelCatalogEntry {
id: "qwen-code/qwq-32b".into(),
display_name: "QwQ 32B (CLI)".into(),
provider: "qwen-code".into(),
tier: ModelTier::Balanced,
context_window: 131_072,
max_output_tokens: 65_536,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["qwq".into()],
},
// ══════════════════════════════════════════════════════════════
// Chutes.ai (5)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -3549,7 +3618,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 38);
assert_eq!(catalog.list_providers().len(), 39);
}
#[test]
@@ -3917,4 +3986,29 @@ mod tests {
let entry = catalog.find_model("claude-code").unwrap();
assert_eq!(entry.id, "claude-code/sonnet");
}
#[test]
fn test_qwen_code_provider() {
let catalog = ModelCatalog::new();
let qc = catalog.get_provider("qwen-code").unwrap();
assert_eq!(qc.display_name, "Qwen Code");
assert!(!qc.key_required);
}
#[test]
fn test_qwen_code_models() {
let catalog = ModelCatalog::new();
let models = catalog.models_by_provider("qwen-code");
assert_eq!(models.len(), 3);
assert!(models.iter().any(|m| m.id == "qwen-code/qwen3-coder"));
assert!(models.iter().any(|m| m.id == "qwen-code/qwen-coder-plus"));
assert!(models.iter().any(|m| m.id == "qwen-code/qwq-32b"));
}
#[test]
fn test_qwen_code_aliases() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("qwen-code").unwrap();
assert_eq!(entry.id, "qwen-code/qwen3-coder");
}
}
+27 -12
View File
@@ -20,6 +20,8 @@ let qrDataUrl = ''; // latest QR code as data:image/png;base64,...
let connStatus = 'disconnected'; // disconnected | qr_ready | connected
let qrExpired = false;
let statusMessage = 'Not started';
let reconnectAttempt = 0; // exponential backoff counter
const MAX_RECONNECT_DELAY = 60_000; // cap at 60s
// ---------------------------------------------------------------------------
// Baileys connection
@@ -79,11 +81,12 @@ async function startConnection() {
console.log(`[gateway] Connection closed: ${reason} (${statusCode})`);
if (statusCode === DisconnectReason.loggedOut) {
// User logged out from phone — clear auth and stop
// User logged out from phone — clear auth and stop (truly non-recoverable)
connStatus = 'disconnected';
statusMessage = 'Logged out. Generate a new QR code to reconnect.';
qrDataUrl = '';
sock = null;
reconnectAttempt = 0;
// Remove auth store so next connect gets a fresh QR
const fs = require('node:fs');
const path = require('node:path');
@@ -91,18 +94,16 @@ async function startConnection() {
if (fs.existsSync(authPath)) {
fs.rmSync(authPath, { recursive: true, force: true });
}
} else if (statusCode === DisconnectReason.restartRequired ||
statusCode === DisconnectReason.timedOut) {
// Recoverable — reconnect automatically
console.log('[gateway] Reconnecting...');
statusMessage = 'Reconnecting...';
setTimeout(() => startConnection(), 2000);
} else {
// QR expired or other non-recoverable close
qrExpired = true;
// All other disconnect reasons are recoverable — reconnect with backoff
// Covers: restartRequired(515), timedOut(408), connectionClosed(428),
// connectionLost(408), connectionReplaced(440), badSession(500), etc.
reconnectAttempt++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), MAX_RECONNECT_DELAY);
console.log(`[gateway] Reconnecting in ${delay}ms (attempt ${reconnectAttempt})...`);
statusMessage = `Reconnecting (attempt ${reconnectAttempt})...`;
connStatus = 'disconnected';
statusMessage = 'QR code expired. Click "Generate New QR" to retry.';
qrDataUrl = '';
setTimeout(() => startConnection(), delay);
}
}
@@ -110,6 +111,7 @@ async function startConnection() {
connStatus = 'connected';
qrExpired = false;
qrDataUrl = '';
reconnectAttempt = 0;
statusMessage = 'Connected to WhatsApp';
console.log('[gateway] Connected to WhatsApp!');
}
@@ -336,7 +338,20 @@ server.listen(PORT, '127.0.0.1', () => {
console.log(`[gateway] WhatsApp Web gateway listening on http://127.0.0.1:${PORT}`);
console.log(`[gateway] OpenFang URL: ${OPENFANG_URL}`);
console.log(`[gateway] Default agent: ${DEFAULT_AGENT}`);
console.log('[gateway] Waiting for POST /login/start to begin QR flow...');
// Auto-connect if credentials already exist from a previous session
const fs = require('node:fs');
const path = require('node:path');
const credsPath = path.join(__dirname, 'auth_store', 'creds.json');
if (fs.existsSync(credsPath)) {
console.log('[gateway] Found existing credentials — auto-connecting...');
startConnection().catch((err) => {
console.error('[gateway] Auto-connect failed:', err.message);
statusMessage = 'Auto-connect failed. Use POST /login/start to retry.';
});
} else {
console.log('[gateway] No credentials found. Waiting for POST /login/start to begin QR flow...');
}
});
// Graceful shutdown