mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
fix community issues
This commit is contained in:
Generated
+14
-14
@@ -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
@@ -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"
|
||||
|
||||
@@ -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() !== ''">✓ 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 →</a>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user