Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 72 additions & 17 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,17 @@ struct ProviderConfig {
}

fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
let (api_key_account, endpoint_account) = match kind {
"llm" => (CredentialAccount::ArkApiKey, CredentialAccount::ArkEndpoint),
"asr" => (CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint),
let (api_key_account, endpoint_account, api_key_required) = match kind {
"llm" => (
CredentialAccount::ArkApiKey,
CredentialAccount::ArkEndpoint,
false,
),
"asr" => (
CredentialAccount::AsrApiKey,
CredentialAccount::AsrEndpoint,
true,
),
_ => return Err(format!("unknown provider kind: {kind}")),
};
let api_key = CredentialsVault::get(api_key_account)
Expand All @@ -176,7 +184,7 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
let base_url = CredentialsVault::get(endpoint_account)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
if api_key_required && api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
Comment on lines +187 to 188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip auth header for keyless LLM model discovery

This change allows empty API keys for llm, but the model-listing path still unconditionally sends Authorization: Bearer {api_key} in fetch_provider_models. When the key is empty, some OpenAI-compatible endpoints reject the empty Bearer credential, so the Settings “Fetch models” flow fails even though keyless LLM polish/validation now works. Please make model discovery omit Authorization when the key is blank, matching the new keyless behavior.

Useful? React with 👍 / 👎.

}
if base_url.trim().is_empty() {
Expand Down Expand Up @@ -217,18 +225,17 @@ async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, S
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"请求超时".to_string()
} else {
format!("网络错误: {e}")
}
})?;
let mut request = client.get(&url);
if !config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", config.api_key));
}
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
"请求超时".to_string()
} else {
format!("网络错误: {e}")
}
})?;
let status = response.status();
let body = response
.text()
Expand Down Expand Up @@ -550,11 +557,17 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {}

#[cfg(test)]
mod tests {
use super::{models_url, parse_model_ids, persist_settings, SettingsWriter};
use super::{
fetch_provider_models, models_url, parse_model_ids, persist_settings, ProviderConfig,
SettingsWriter,
};
use crate::types::{
HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, UserPreferences,
};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;
use std::thread;

#[derive(Default)]
struct FakeSettingsWriter {
Expand Down Expand Up @@ -630,4 +643,46 @@ mod tests {
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1);
}

#[tokio::test]
async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();

let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 8192];
let mut request = Vec::new();
loop {
let n = stream.read(&mut buf).unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&request);
assert!(!request_text.contains("Authorization: Bearer"));

let body = r#"{"data":[{"id":"m1"},{"id":"m2"}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});

let models = fetch_provider_models(&ProviderConfig {
base_url: format!("http://{}", addr),
api_key: String::new(),
})
.await
.unwrap();

assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]);
server.join().unwrap();
}
}
17 changes: 7 additions & 10 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1589,9 +1589,6 @@ async fn polish_text(
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
anyhow::bail!("ark api key missing");
}
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore no-key guard before default Ark endpoint fallback

In polish_text the API-key emptiness check was removed, but this function still falls back to the public Ark URL when no endpoint is configured. That means users with an empty ArkApiKey (including fresh installs with default Light mode) now send raw transcript content to https://ark.cn-beijing.volces.com/... and only fail after the network call, whereas previously the request was blocked locally. This is a privacy and behavior regression introduced by this commit; the same pattern also appears in translate_text and answer_chat_dispatch.

Useful? React with 👍 / 👎.

.unwrap_or_else(|| "deepseek-v3-2".to_string());
Expand Down Expand Up @@ -1634,9 +1631,6 @@ async fn translate_text(
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
anyhow::bail!("ark api key missing");
}
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
Expand Down Expand Up @@ -2154,9 +2148,6 @@ where
C: Fn() -> bool + Send + Sync,
{
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
if api_key.is_empty() {
anyhow::bail!("ark api key missing");
}
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
Expand All @@ -2170,7 +2161,13 @@ where
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider
.answer_chat_streaming(messages, working_languages, front_app, on_delta, should_cancel)
.answer_chat_streaming(
messages,
working_languages,
front_app,
on_delta,
should_cancel,
)
.await?)
}

Expand Down
88 changes: 70 additions & 18 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,6 @@ impl OpenAICompatibleLLMProvider {
system_prompt: &str,
user_prompt: &str,
) -> Result<String, LLMError> {
if self.config.api_key.trim().is_empty() {
return Err(LLMError::MissingCredentials);
}

let url = chat_completions_url(&self.config.base_url);
let body = json!({
"model": self.config.model,
Expand All @@ -171,8 +167,10 @@ impl OpenAICompatibleLLMProvider {
let mut request = self
.client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", self.config.api_key));
.header("Content-Type", "application/json");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
Expand Down Expand Up @@ -222,10 +220,6 @@ impl OpenAICompatibleLLMProvider {
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
if self.config.api_key.trim().is_empty() {
return Err(LLMError::MissingCredentials);
}

let mut msgs: Vec<Value> = Vec::with_capacity(history.len() + 1);
msgs.push(json!({ "role": "system", "content": system_prompt }));
for m in history {
Expand All @@ -252,8 +246,10 @@ impl OpenAICompatibleLLMProvider {
.client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Authorization", format!("Bearer {}", self.config.api_key));
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
Expand Down Expand Up @@ -310,7 +306,10 @@ impl OpenAICompatibleLLMProvider {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:")) else {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
Expand All @@ -320,7 +319,10 @@ impl OpenAICompatibleLLMProvider {
let v: Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
log::warn!("[llm] SSE parse skip: {e}; payload preview: {}", safe_str_slice(payload, 80));
log::warn!(
"[llm] SSE parse skip: {e}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
Expand Down Expand Up @@ -382,9 +384,7 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let app = front_app
.map(str::trim)
.filter(|s| !s.is_empty());
let app = front_app.map(str::trim).filter(|s| !s.is_empty());

if langs.is_empty() && app.is_none() {
return None;
Expand Down Expand Up @@ -832,6 +832,9 @@ pub mod prompts {
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;

#[test]
fn clean_polish_output_strips_think_tag_block() {
Expand Down Expand Up @@ -900,11 +903,60 @@ mod tests {

#[test]
fn compose_system_prompt_prefers_correct_spelling_for_hotwords() {
let prompt = compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]);
let prompt =
compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]);

assert!(prompt.contains("用户希望以下写法在输出中保持准确"));
assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出"));
assert!(prompt.contains("- GitHub"));
assert!(prompt.contains("- OpenLess"));
}

#[tokio::test]
async fn chat_completion_omits_authorization_when_api_key_is_empty() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();

let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 8192];
let mut request = Vec::new();
loop {
let n = stream.read(&mut buf).unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&request);
assert!(!request_text.contains("Authorization: Bearer"));

let body = r#"{"choices":[{"message":{"content":"最终文本。"}}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});

let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new(
"ark",
"Doubao Ark",
format!("http://{}", addr),
"",
"deepseek-v3-2",
));

let output = provider
.polish("原文", PolishMode::Raw, &[], &[], None)
.await
.unwrap();
assert_eq!(output, "最终文本。");

server.join().unwrap();
}
}
Loading