feat(m174): 助手(AI)提供商配置真接线 + 「测试连接」

设置页新增第 6 组「助手」:提供商四态 / 端点 base_url / 模型名 / API 密钥(默认掩码,
点「显示」才明文)+ 保存 + 测试连接,四类失败(鉴权 / 模型不存在 / 不可达 / 超时)各自
一句文案 + 目标与耗时。

取舍三条:

1. 读写走 `core::setting` 的 schema API(`SettingStore::load` 现读 → `set` → `save`),
   不经 `AppCommand::SetSetting`——那条指令是可序列化 DTO,`ai.api_key` 走它等于让明文进
   事件流/日志。与 `SettingsBridge::persist` 写外观是同一条做法。
2. 「测试连接」不另写一套 HTTP:`unlucky_cli::commands::agent::probe` 现读设置文件、
   交给 `RigProvider`(真 provider)发一次最小补全,带 8 秒时限;分类只看错误文本
   (rig 只给 Display),四类各有一条真 TCP 假服务器用例钉住。
3. `ProviderKind` 补 `from_setting` / `setting_value`:serde 的 `rename_all = snake_case`
   产出 `open_ai_compatible`,与 schema 的 `openai_compatible` 不是同一个词——这正是
   M174 之前「agent 侧一次都没读过设置」的根因。

守卫(都做变异验证,见 mission 报告):密钥不出 `Debug`/`detail`/DTO;未配置时工厂构造
计数为 0;改完 base_url 后探测打到新地址;空/非法值被 schema 拦下且文件不动。

i18n:49 条中英各一(金表按 `Key::ALL` 位置插入);presenter 的 ai_group 补上 schema 允许
的 `disabled` 选项与一行「已配置 N 字符」的只读状态(仍然不显示值)。
This commit is contained in:
AskaEth committed 2026-11-08 15:57:25 +08:00
1 parent 82f3bbbff6
commit 9cb605f316
23 files changed
+2939 -73

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
pub mod ports;
pub mod probe;
pub mod serve;
use serde_json::{json, Value};
+354
View File
@@ -0,0 +1,354 @@
//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现**
//! ([`RigProvider`])发一次最小请求,并把失败分成四类。
//!
//! ## 为什么分类不在 provider 层
//!
//! `brain::rig` 把 rig 的错误压成一句 `Display` 文本(`ProviderError::new(error.to_string())`),
//! 那里拿不到结构化的状态码;本模块是**唯一**把「错误文本」翻译成人能看懂的四类的地方,
//! 四类各自有一条真 TCP 假服务器用例钉住(`probe/tests.rs`),谁都不许靠猜。
//!
//! | 类 | 判据(错误文本里) | 用户看到的 |
//! |---|---|---|
//! | [`ProbeOutcome::Auth`] | `401` / `403` / `unauthorized` / `invalid_api_key` | 密钥不对(或没有权限) |
//! | [`ProbeOutcome::ModelNotFound`] | `404` / `model_not_found` / `does not exist` | 端点通了,但没有这个模型名 |
//! | [`ProbeOutcome::Unreachable`] | 连接被拒 / DNS / TLS / `error sending request` | 端点不可达 |
//! | [`ProbeOutcome::Timeout`] | 超时 | 超时(网络太慢或端点不响应) |
//!
//! ## 三条硬纪律
//!
//! 1. **配置不完整时一个请求都不发**:[`ProbeOutcome::NotConfigured`] 在造 provider **之前**返回,
//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。
//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把
//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),`ProbeOutcome` 里没有密钥字段。
//! 3. **读的是文件里现在那一份**:[`probe_file`] 每次现读,所以「设置页保存完再测」测的就是
//! 刚存下的 `ai.base_url`——不是启动时缓存的旧值。
use std::path::Path;
use std::time::Duration;
use unlucky_agent::brain::provider::{ProviderConfig, ProviderError, ProviderKind};
use unlucky_agent::brain::rig::RigProvider;
use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory};
use unlucky_agent::redact::{redact_text, MASK};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 一次「测试连接」的默认时限。
///
/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应(UI 侧有「正在测试」
/// 态,但那不是让人等半分钟的理由)。
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
/// 探测请求的模型名之外只发一句话:够触发一次真实补全,又不烧 token。
const PING: &str = "ping";
/// 从设置文件读出来的 AI 四项(快照)。
///
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码——与
/// `ProviderConfig` 同一条纪律:`{:?}` 是日志/panic 消息的默认出口。
#[derive(Clone, PartialEq, Eq)]
pub struct AiSettings {
/// `ai.enabled`
pub enabled: bool,
/// `ai.provider`(设置词表:`openai_compatible` / `anthropic` / `ollama` / `disabled`)
pub provider: String,
/// `ai.base_url`(空 = 用该形态的默认端点)
pub base_url: String,
/// `ai.model`
pub model: String,
/// `ai.api_key`(空/缺 = 没配置)
pub api_key: Option<String>,
}
impl std::fmt::Debug for AiSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiSettings")
.field("enabled", &self.enabled)
.field("provider", &self.provider)
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("api_key", &self.api_key.as_deref().map(|_| MASK))
.finish()
}
}
/// 缺哪一项配置(`NotConfigured` 的载荷:界面上要说清楚「差什么」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Missing {
/// `ai.enabled=false` 或 `ai.provider=disabled`
Disabled,
/// `ai.provider` 是空/未知值
Provider,
/// 该形态需要端点,但 `ai.base_url` 是空的
BaseUrl,
/// `ai.model` 是空的
Model,
/// 该形态(Anthropic)必须有密钥,但 `ai.api_key` 是空的
ApiKey,
}
impl Missing {
/// 稳定名(CLI 输出 / 日志 / 测试断言用;界面走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Provider => "provider",
Self::BaseUrl => "base_url",
Self::Model => "model",
Self::ApiKey => "api_key",
}
}
}
/// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
/// 配置不完整:**没有发出任何请求**。
NotConfigured {
/// 缺的是哪一项。
missing: Missing,
},
/// 通了(拿到一次真实补全的回复)。
Passed {
/// 实际请求的端点(`resolved_base_url`)。
base_url: String,
/// 实际请求的模型名。
model: String,
},
/// 鉴权失败(401 / 403 / `invalid_api_key`)。
Auth {
/// 脱敏后的原因。
detail: String,
},
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound {
/// 脱敏后的原因。
detail: String,
},
/// 端点不可达(连接被拒 / DNS / TLS)。
Unreachable {
/// 脱敏后的原因。
detail: String,
},
/// 超时。
Timeout {
/// 脱敏后的原因(含时限)。
detail: String,
},
/// 其它(协议/解析/未知 HTTP 码):不硬塞进上面四类。
Failed {
/// 脱敏后的原因。
detail: String,
},
}
impl ProbeOutcome {
/// 是否「通」。
#[must_use]
pub fn is_pass(&self) -> bool {
matches!(self, Self::Passed { .. })
}
/// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` / `not_configured` / `failed`)。
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::NotConfigured { .. } => "not_configured",
Self::Passed { .. } => "passed",
Self::Auth { .. } => "auth",
Self::ModelNotFound { .. } => "model_not_found",
Self::Unreachable { .. } => "unreachable",
Self::Timeout { .. } => "timeout",
Self::Failed { .. } => "failed",
}
}
}
/// 错误文本 → 四类。**顺序有讲究**:先超时(超时文本里可能带 URL 里的 `401` 之类数字),
/// 再鉴权,再模型,最后不可达。
#[must_use]
pub fn classify(message: &str) -> &'static str {
let text = message.to_lowercase();
let has = |needle: &str| text.contains(needle);
if has("timed out") || has("timeout") || has("deadline exceeded") {
"timeout"
} else if has("401")
|| has("403")
|| has("unauthorized")
|| has("invalid_api_key")
|| has("invalid api key")
|| has("authentication")
{
"auth"
} else if has("404")
|| has("model_not_found")
|| has("model not found")
|| has("no such model")
|| (has("model") && has("does not exist"))
{
"model_not_found"
} else if has("connection refused")
|| has("dns")
|| has("failed to connect")
|| has("connect error")
|| has("error sending request")
|| has("network is unreachable")
|| has("unreachable")
|| has("tls")
|| has("certificate")
{
"unreachable"
} else {
"failed"
}
}
impl AiSettings {
/// 从设置文件读四项(文件缺失/坏掉时给「全默认」= 未配置,不报错)。
#[must_use]
pub fn load(path: &Path) -> Self {
let store = SettingStore::load(path, launcher_schema());
let doc = store.doc();
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
let api_key = doc
.effective_str("ai.api_key")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Self {
enabled: doc
.effective("ai.enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
provider: text("ai.provider"),
base_url: text("ai.base_url"),
model: text("ai.model"),
api_key,
}
}
/// 四项 → [`ProviderConfig`];缺项时给出**具体缺哪一项**(不发请求)。
///
/// # Errors
/// 禁用、端点/模型/密钥缺失或 `ai.provider` 不是已知取值时返回 [`Missing`]。
pub fn to_config(&self) -> Result<ProviderConfig, Missing> {
if !self.enabled {
return Err(Missing::Disabled);
}
let Some(kind) = ProviderKind::from_setting(&self.provider) else {
return Err(Missing::Provider);
};
if kind == ProviderKind::Disabled {
return Err(Missing::Disabled);
}
let base_url = self.base_url.trim();
if base_url.is_empty() && kind.default_base_url().is_empty() {
return Err(Missing::BaseUrl);
}
let model = self.model.trim();
if model.is_empty() {
return Err(Missing::Model);
}
if kind.requires_api_key() && self.api_key.is_none() {
return Err(Missing::ApiKey);
}
let mut config = match kind {
ProviderKind::OpenAiCompatible => ProviderConfig::openai_compatible(base_url, model),
ProviderKind::Anthropic => ProviderConfig::anthropic(model),
ProviderKind::Ollama => ProviderConfig::ollama(model),
// 上面已经拦掉;留着是因为穷尽匹配比 `_ =>` 值钱(加形态时编译器会提醒)。
ProviderKind::Disabled => return Err(Missing::Disabled),
};
if !base_url.is_empty() {
config.base_url = base_url.to_string();
}
if let Some(key) = self.api_key.as_deref() {
config = config.with_api_key(key);
}
Ok(config)
}
/// 本次的密钥(脱敏用;空值给 `None`)。
fn secret(&self) -> Option<&str> {
self.api_key.as_deref().filter(|key| !key.trim().is_empty())
}
}
/// 读设置文件并测一次连接(真 provider、真网络、带时限)。
#[must_use]
pub fn probe_file(path: &Path, timeout: Duration) -> ProbeOutcome {
probe_with(&RigProvider::factory(), &AiSettings::load(path), timeout)
}
/// 测一次连接(工厂可注入:单测用计数假件证明「未配置时一次都没发」)。
///
/// # Panics
///
/// 在已有 tokio 运行时上下文里调用会 panic(`core::download::blocking::block_on` 的既有约定);
/// 本函数的两个调用方(GUI 的 thread-pool 任务、CLI)都不在运行时里。
#[must_use]
pub fn probe_with(
factory: &ProviderFactory,
settings: &AiSettings,
timeout: Duration,
) -> ProbeOutcome {
let config = match settings.to_config() {
Ok(config) => config,
Err(missing) => return ProbeOutcome::NotConfigured { missing },
};
let base_url = config.resolved_base_url().to_string();
let model = config.model.clone();
let provider = match factory(&config) {
Ok(provider) => provider,
Err(error) => return outcome_from_error(&error, settings),
};
let request = ChatRequest {
model: model.clone(),
messages: vec![ChatMessage::user(PING)],
tools: Vec::new(),
temperature: Some(0.0),
max_tokens: Some(16),
};
unlucky_core::download::blocking::block_on(async move {
match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await {
Ok(Ok(_reply)) => ProbeOutcome::Passed { base_url, model },
Ok(Err(error)) => outcome_from_error(&error, settings),
Err(_) => ProbeOutcome::Timeout {
detail: format!("{} s", timeout.as_secs_f32()),
},
}
})
}
/// provider 错误 → 分类结果(`detail` 先脱敏再出门)。
fn outcome_from_error(error: &ProviderError, settings: &AiSettings) -> ProbeOutcome {
let detail = scrub(&error.to_string(), settings.secret());
match classify(&error.to_string()) {
"auth" => ProbeOutcome::Auth { detail },
"model_not_found" => ProbeOutcome::ModelNotFound { detail },
"unreachable" => ProbeOutcome::Unreachable { detail },
"timeout" => ProbeOutcome::Timeout { detail },
_ => ProbeOutcome::Failed { detail },
}
}
/// 文本脱敏:先走 agent 的通用脱敏(主目录 / Bearer),再把本次密钥原文换成 [`MASK`]。
///
/// 第二道是必须的:`redact_text` 认不出「某个服务端回显的自定义密钥」,只有本模块知道本次的
/// 密钥原文。长度上限 400:错误体可能是整页 HTML,界面上放不下,日志里也没必要。
pub fn scrub(text: &str, secret: Option<&str>) -> String {
let mut out = redact_text(text);
if let Some(secret) = secret.filter(|value| !value.trim().is_empty()) {
out = out.replace(secret, MASK);
}
out.chars().take(400).collect()
}
#[cfg(test)]
#[path = "probe/tests.rs"]
mod tests;
@@ -0,0 +1,215 @@
//! 「测试连接」的守卫(M174)。四类失败**各有一条真 TCP 假服务器**用例——不是拿字符串假装的。
//!
//! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---|
//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `classify` 删掉 `401` 分支 |
//! | `missing_model_is_model_not_found` | 404/model_not_found → 模型不存在 | 把 `model_not_found` 归进 auth |
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 删 `connection refused` 分支 |
//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` |
//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 |
//! | `incomplete_config_never_builds_a_provider` | 未配置 ⇒ 工厂构造计数 **0** | 把校验挪到工厂调用之后 |
//! | `saved_base_url_is_the_address_actually_dialed` | 改完设置再测,请求打到**新**地址 | 用启动时缓存的值 / 写死端点 |
//! | `key_never_leaks_into_outcome_or_debug` | 密钥不进 detail / Debug | 删掉 `scrub` 的 replace |
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_agent::brain::rig::RigProvider;
use super::{probe_with, AiSettings, ProbeOutcome};
/// 假服务器的行为。
enum Behaviour {
/// 立刻回这一份(状态行 + Content-Type + body)。
Reply(&'static str, &'static str, &'static str),
/// 收下请求但**永远不回**(超时用例)。
Silent,
}
/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求头,按 `behaviour` 回。
struct Server {
port: u16,
hits: Arc<Mutex<Vec<String>>>,
}
impl Server {
fn start(behaviour: Behaviour) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑本地端口");
let port = listener.local_addr().expect("本地地址").port();
let hits = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
handle(stream, &behaviour, &sink);
}
});
Self { port, hits }
}
fn base_url(&self) -> String {
format!("http://127.0.0.1:{}/v1", self.port)
}
fn hits(&self) -> Vec<String> {
self.hits.lock().expect("锁").clone()
}
}
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<String>>>) {
let mut head = [0_u8; 4096];
let mut text = String::new();
// 请求头到 `\r\n\r\n` 为止就够了:本模块只关心方法 + 路径(正文是 `ping`)。
while !text.contains("\r\n\r\n") {
match stream.read(&mut head) {
Ok(0) => break,
Ok(n) => text.push_str(&String::from_utf8_lossy(&head[..n])),
Err(_) => return,
}
}
sink.lock().expect("锁").push(text.clone());
match behaviour {
Behaviour::Silent => {
// 拖住这条连接(客户端会先超时):比客户端时限长得多,收尾时自己断开。
std::thread::sleep(Duration::from_secs(5));
}
Behaviour::Reply(status, content_type, body) => {
let response = format!(
"{status}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}",
len = body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
}
}
fn settings(base_url: &str, model: &str) -> AiSettings {
AiSettings {
enabled: true,
provider: "openai_compatible".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
api_key: Some("sk-test-not-a-real-key".to_string()),
}
}
const AUTH_BODY: &str = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#;
const MISSING_MODEL_BODY: &str =
r#"{"error":{"message":"model_not_found: no such model gpt-nope","code":"model_not_found"}}"#;
/// 一份最小但合法的 OpenAI 流式回复(rig 走 `model.stream(..)`,要 SSE 不是整包 JSON)。
const SSE_BODY: &str = concat!(
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\n",
"data: [DONE]\n\n"
);
#[test]
fn auth_failure_is_classified_as_auth() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
let ProbeOutcome::Auth { detail } = &outcome else {
panic!("401 必须归到鉴权失败,实际 {outcome:?}");
};
assert!(!detail.is_empty());
assert_eq!(outcome.kind(), "auth");
// 请求确实发出去了(否则「鉴权失败」是凭空说的)。
assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过");
}
#[test]
fn missing_model_is_model_not_found() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 404 Not Found",
"application/json",
MISSING_MODEL_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-nope"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"model_not_found",
"404/model_not_found 必须归到模型不存在,实际 {outcome:?}"
);
}
#[test]
fn closed_port_is_unreachable() {
// 绑一个端口再放掉:这个端口上没人监听 = 连接被拒。
let port = {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口");
listener.local_addr().expect("地址").port()
};
let outcome = probe_with(
&RigProvider::factory(),
&settings(&format!("http://127.0.0.1:{port}/v1"), "gpt-test"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"unreachable",
"没人监听的端口必须归到端点不可达,实际 {outcome:?}"
);
}
#[test]
fn silent_server_times_out() {
let server = Server::start(Behaviour::Silent);
let started = std::time::Instant::now();
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_millis(400),
);
assert_eq!(
outcome.kind(),
"timeout",
"不回应的端点必须归到超时,实际 {outcome:?}"
);
assert!(
started.elapsed() < Duration::from_secs(3),
"超时必须在时限附近收手,实际 {:?}",
started.elapsed()
);
}
#[test]
fn success_is_one_real_completion() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 200 OK",
"text/event-stream",
SSE_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
match &outcome {
ProbeOutcome::Passed { base_url, model } => {
assert_eq!(base_url, &server.base_url());
assert_eq!(model, "gpt-test");
}
other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"),
}
}
// 不发请求那一半的守卫(未配置不建 provider / 改完设置打新地址 / 密钥不外流)在
// `probe/tests_guards.rs`:本文件只放真 TCP 假服务器那五条与密钥掩码那一条。
#[path = "tests_guards.rs"]
mod guards;
@@ -0,0 +1,275 @@
//! 「测试连接」的**不发请求**那一半守卫(M174):未配置不建 provider / 改完设置打新地址 /
//! 密钥不外流 / 文件读取。与 `probe/tests.rs` 的分工:那边起真 TCP 假服务器验四类失败,
//! 这边只验「什么时候不发」「发到哪」「发出去的东西里有什么」。
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use unlucky_agent::brain::provider::ProviderConfig;
use unlucky_agent::brain::{
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
};
use unlucky_agent::redact::MASK;
use super::{settings, Behaviour, Server, AUTH_BODY};
use crate::commands::agent::probe::{probe_file, probe_with, AiSettings, Missing, ProbeOutcome};
#[test]
fn incomplete_config_never_builds_a_provider() {
struct Counter;
impl ChatProvider for Counter {
fn config(&self) -> &ProviderConfig {
unreachable!("不该被造出来")
}
fn complete<'a>(
&'a self,
_request: &'a ChatRequest,
_observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
unreachable!("不该被造出来")
}
}
let builds = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&builds);
let factory: unlucky_agent::brain::ProviderFactory =
Arc::new(move |_config: &ProviderConfig| {
counter.fetch_add(1, Ordering::SeqCst);
Ok(Arc::new(Counter) as Arc<dyn ChatProvider>)
});
// 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(Anthropic)
let mut cases = vec![
(
AiSettings {
enabled: false,
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Disabled,
),
(
AiSettings {
base_url: String::new(),
..settings("", "m")
},
Missing::BaseUrl,
),
(
AiSettings {
model: String::new(),
..settings("http://127.0.0.1:1/v1", "")
},
Missing::Model,
),
(
AiSettings {
provider: "anthropic".to_string(),
base_url: String::new(),
api_key: None,
..settings("", "claude-sonnet-4")
},
Missing::ApiKey,
),
(
AiSettings {
provider: "openai-compatible".to_string(),
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Provider,
),
];
// `..::settings(..)` 里那些字段会被逐个覆盖,这里只要求「不全的配置都能被拦下」。
for (case, expected) in cases.drain(..) {
let outcome = probe_with(&factory, &case, Duration::from_millis(50));
assert_eq!(
outcome,
ProbeOutcome::NotConfigured { missing: expected },
"配置不全时必须返回 NotConfigured({expected:?}),实际 {outcome:?}"
);
}
assert_eq!(
builds.load(Ordering::SeqCst),
0,
"未配置时一个 provider 都不许造(更不许发请求)"
);
}
/// 守卫:设置页保存完再测,请求打到的是**文件里现在那个** `ai.base_url`。
#[test]
fn saved_base_url_is_the_address_actually_dialed() {
let old = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let new = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let path = temp_config("base-url");
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
old.base_url()
),
);
let first = probe_file(&path, Duration::from_secs(5));
assert!(!first.is_pass(), "假服务器回 401,不该算通过");
assert_eq!(old.hits().len(), 1, "第一次必须打到旧地址");
// 用户在设置页改了 base_url(同一份 schema、同一个文件)之后再测一次。
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
new.base_url()
),
);
let second = probe_file(&path, Duration::from_secs(5));
assert!(!second.is_pass());
assert_eq!(
new.hits().len(),
1,
"改完 base_url 之后请求必须打到新地址(读的是文件里现在那一份)"
);
assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次");
let hit = &new.hits()[0];
assert!(
hit.contains("POST /v1/chat/completions"),
"OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}",
hit.lines().next().unwrap_or("")
);
}
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
#[test]
fn key_never_leaks_into_outcome_or_debug() {
const SECRET: &str = "sk-live-LEAK-MARKER-9f3a";
// 服务端把 key 原样回显(有些网关会这么干)。
let body = r#"{"error":{"message":"Incorrect API key provided: sk-live-LEAK-MARKER-9f3a","code":"invalid_api_key"}}"#;
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
body,
));
let settings = AiSettings {
api_key: Some(SECRET.to_string()),
..settings(&server.base_url(), "gpt-test")
};
let outcome = probe_with(
&unlucky_agent::brain::rig::RigProvider::factory(),
&settings,
Duration::from_secs(5),
);
let printed = format!("{outcome:?}");
assert!(
!printed.contains(SECRET),
"ProbeOutcome 里出现了密钥明文:{printed}"
);
assert!(
printed.contains(MASK),
"被掩码后应当看得见掩码标记:{printed}"
);
let debug = format!("{settings:?}");
assert!(
!debug.contains(SECRET),
"AiSettings 的 Debug 漏了密钥:{debug}"
);
assert!(
debug.contains(MASK),
"AiSettings 的 Debug 应当显示掩码:{debug}"
);
}
#[test]
fn settings_load_reads_the_five_keys() {
let path = temp_config("load");
write_config(
&path,
r#"{"ai":{"enabled":false,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3","api_key":"sk-x"}}"#,
);
let settings = AiSettings::load(&path);
assert!(!settings.enabled);
assert_eq!(settings.provider, "ollama");
assert_eq!(settings.base_url, "http://127.0.0.1:11434");
assert_eq!(settings.model, "llama3");
assert_eq!(settings.api_key.as_deref(), Some("sk-x"));
// 空的 api_key(null / 空串)读成 `None`,不是 `Some("")`。
let empty = temp_config("empty-key");
write_config(
&empty,
r#"{"ai":{"provider":"ollama","model":"llama3","api_key":""}}"#,
);
assert_eq!(AiSettings::load(&empty).api_key, None);
// 文件不存在也不 panic(`effective_*` 给 schema 默认值 = 未配置,不是坏值)。
let missing = temp_config("missing");
let settings = AiSettings::load(&missing);
assert_eq!(
settings.provider, "openai_compatible",
"缺文件时是 schema 默认值"
);
assert!(settings.model.is_empty());
assert_eq!(settings.api_key, None);
assert_eq!(settings.to_config(), Err(Missing::BaseUrl));
}
// ------------------------------------------------------------------ 夹具
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-probe-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
dir.join("config/launcher.json")
}
fn write_config(path: &PathBuf, body: &str) {
std::fs::write(path, body).expect("写配置文件");
}
/// 让编译器确认 `ChatReply` / `ProviderError` 的导入是被用的(构造一个不可能的 provider
/// 用到它们),同时钉住「工厂返回错误也要分类」这条支路。
#[test]
fn factory_error_is_classified_too() {
struct Broken;
impl ChatProvider for Broken {
fn config(&self) -> &ProviderConfig {
unreachable!()
}
fn complete<'a>(
&'a self,
_request: &'a ChatRequest,
_observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async { Ok(ChatReply::default()) })
}
}
let _ = Broken;
let factory: unlucky_agent::brain::ProviderFactory =
Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key")));
let outcome = probe_with(
&factory,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"auth",
"工厂阶段的错误也要分类:{outcome:?}"
);
}