fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类

P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
This commit is contained in:
AskaEth committed 2026-11-08 16:20:34 +08:00
1 parent 9cb605f316
commit 93d02ab12b
18 files changed
+825 -397

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
}; };
pub use exec::{ToolExecOutcome, ToolExecutor}; pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind}; pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 一轮对话最多几轮工具调用。 /// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8; pub const DEFAULT_MAX_STEPS: usize = 8;
+41 -117
View File
@@ -14,6 +14,11 @@ use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。 /// Ollama 默认端点(research/02 §5.2)。
@@ -203,18 +208,22 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> { pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() { if !self.is_enabled() {
return Err(ProviderError::new( return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
)); ));
} }
if self.model.trim().is_empty() { if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)")); return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
} }
if self.resolved_base_url().trim().is_empty() { if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要填写端点(base_url)", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要填写端点(base_url)", self.kind.display_name()),
))); ));
} }
if self.kind.requires_api_key() if self.kind.requires_api_key()
&& self && self
@@ -222,10 +231,10 @@ impl ProviderConfig {
.as_deref() .as_deref()
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
{ {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要 API key", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要 API key", self.kind.display_name()),
))); ));
} }
Ok(()) Ok(())
} }
@@ -268,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
} }
} }
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 /// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError { pub struct ProviderError {
kind: ProviderFailure,
message: String, message: String,
} }
impl ProviderError { impl ProviderError {
/// 从可读文本构造。 /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
kind: ProviderFailure::Other,
message: message.into(), message: message.into(),
} }
} }
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
} }
impl fmt::Display for ProviderError { impl fmt::Display for ProviderError {
@@ -293,108 +320,5 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {} impl std::error::Error for ProviderError {}
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "provider/tests.rs"]
use super::*; mod tests;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
@@ -0,0 +1,62 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
+107
View File
@@ -0,0 +1,107 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+60 -83
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt; use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError; use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::message::{ use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture, ProviderFuture,
}; };
use super::provider::{ProviderConfig, ProviderError, ProviderKind}; use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0"; pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,9 +275,63 @@ fn to_reply(
}) })
} }
/// rig 错误 → 本方错误(文案面向用户)。 /// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
fn map_error(error: RigError) -> ProviderError { fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string()) let report = error.report();
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
} }
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "rig/tests.rs"]
use super::*; mod tests;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
+82
View File
@@ -0,0 +1,82 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
@@ -30,7 +30,6 @@ impl App {
&mut self, &mut self,
message: pages::settings::ai_about::Message, message: pages::settings::ai_about::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::ai::Secret;
use pages::settings::ai_about::Message; use pages::settings::ai_about::Message;
match message { match message {
@@ -50,8 +49,8 @@ impl App {
self.settings.ai_about.model = value; self.settings.ai_about.model = value;
self.touch(); self.touch();
} }
Message::EditKey(value) => { Message::EditKey(secret) => {
self.settings.ai_about.key = Secret::new(value); self.settings.ai_about.key = secret;
self.touch(); self.touch();
} }
Message::Reveal(reveal) => { Message::Reveal(reveal) => {
@@ -313,7 +313,9 @@ fn saving_the_ai_form_writes_the_real_settings_file() {
))); )));
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string()))); let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string()))); let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
let _ = app.update(ai(Ai::EditKey("sk-live-app-path".to_string()))); let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
"sk-live-app-path",
))));
let _ = app.update(ai(Ai::Save)); let _ = app.update(ai(Ai::Save));
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件"); let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
@@ -116,45 +116,79 @@ impl ProbeResult {
}), }),
_ => None, _ => None,
}; };
let (kind, base_url, model, detail) = match outcome { // r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上
ProbeOutcome::NotConfigured { .. } => ( // 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。
ProbeKind::NotConfigured, let (kind, target, detail) = match outcome {
String::new(), ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()),
String::new(), ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()),
String::new(), ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail),
), ProbeOutcome::ModelNotFound { target, detail } => {
ProbeOutcome::Passed { base_url, model } => { (ProbeKind::ModelNotFound, Some(target), detail)
(ProbeKind::Passed, base_url, model, String::new())
} }
ProbeOutcome::Auth { detail } => { ProbeOutcome::Unreachable { target, detail } => {
(ProbeKind::Auth, String::new(), String::new(), detail) (ProbeKind::Unreachable, Some(target), detail)
}
ProbeOutcome::ModelNotFound { detail } => (
ProbeKind::ModelNotFound,
String::new(),
String::new(),
detail,
),
ProbeOutcome::Unreachable { detail } => {
(ProbeKind::Unreachable, String::new(), String::new(), detail)
}
ProbeOutcome::Timeout { detail } => {
(ProbeKind::Timeout, String::new(), String::new(), detail)
}
ProbeOutcome::Failed { detail } => {
(ProbeKind::Failed, String::new(), String::new(), detail)
} }
ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail),
ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail),
}; };
Self { Self {
kind, kind,
missing, missing,
base_url, base_url: target
model, .as_ref()
.map_or_else(String::new, |t| t.base_url.clone()),
model: target.map_or_else(String::new, |t| t.model.clone()),
elapsed_ms, elapsed_ms,
detail, detail,
} }
} }
/// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。
///
/// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态
/// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于
/// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。
#[must_use]
pub fn screenshot_seed(
kind: ProbeKind,
base_url: String,
model: String,
elapsed_ms: u128,
) -> Self {
use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target};
let target = Target { base_url, model };
let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}";
let outcome = match kind {
ProbeKind::Passed => ProbeOutcome::Passed { target },
ProbeKind::Auth => ProbeOutcome::Auth {
target,
detail: detail.to_string(),
},
ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound {
target,
detail: detail.to_string(),
},
ProbeKind::Unreachable => ProbeOutcome::Unreachable {
target,
detail: "Http client error: error sending request".to_string(),
},
ProbeKind::Timeout => ProbeOutcome::Timeout {
target,
detail: "8 s".to_string(),
},
ProbeKind::Failed => ProbeOutcome::Failed {
target,
detail: detail.to_string(),
},
// 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。
ProbeKind::NotConfigured => ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::Model,
},
};
Self::from_outcome(outcome, elapsed_ms)
}
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。 /// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
#[must_use] #[must_use]
pub fn sent_a_request(&self) -> bool { pub fn sent_a_request(&self) -> bool {
+14 -13
View File
@@ -334,25 +334,26 @@ impl State {
state.key_chars = 32; state.key_chars = 32;
} }
other => { other => {
let (kind, missing) = match other { let kind = match other {
"verified" => (ProbeKind::Passed, None), "verified" => ProbeKind::Passed,
"failed-auth" => (ProbeKind::Auth, None), "failed-auth" => ProbeKind::Auth,
"failed-model" => (ProbeKind::ModelNotFound, None), "failed-model" => ProbeKind::ModelNotFound,
"failed-unreachable" => (ProbeKind::Unreachable, None), "failed-unreachable" => ProbeKind::Unreachable,
"failed-timeout" => (ProbeKind::Timeout, None), "failed-timeout" => ProbeKind::Timeout,
_ => return None, _ => return None,
}; };
state.base_url = "https://api.deepseek.com/v1".to_string(); state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = "deepseek-chat".to_string(); state.model = "deepseek-chat".to_string();
state.key_chars = 32; state.key_chars = 32;
state.probe = Probe::Done(ProbeResult { // r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定,
// 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」
// 这类缺陷只在产品上出现、截图永远看不见。
state.probe = Probe::Done(ProbeResult::screenshot_seed(
kind, kind,
missing, state.resolved_base_url(),
base_url: state.resolved_base_url(), state.model.clone(),
model: state.model.clone(), 412,
elapsed_ms: 412, ));
detail: String::new(),
});
} }
} }
Some(state) Some(state)
@@ -1,4 +1,4 @@
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。 //! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。
//! //!
//! | 用例 | 守什么 | 变异 | //! | 用例 | 守什么 | 变异 |
//! |---|---|---| //! |---|---|---|
@@ -7,6 +7,7 @@
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 | //! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` | //! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | //! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
use std::path::PathBuf; use std::path::PathBuf;
@@ -127,3 +128,36 @@ fn external_edit_is_read_back() {
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0"); assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
} }
/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。
///
/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved`
/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与
/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。
#[test]
fn appearance_write_back_keeps_the_ai_keys() {
use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode};
use unlucky_presenter::settings_bridge::SettingsBridge;
let path = temp_config("appearance-keeps-ai");
// 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。
let mut bridge = SettingsBridge::with_path(&path);
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
// 用户在「助手」页保存配置(真落盘、含密钥)。
let mut draft = draft();
draft.key = Secret::new("sk-must-survive");
save(&path, &draft).expect("保存 AI 配置");
// 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"密钥被外观写回盖掉了:{text}"
);
assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}");
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
+68 -9
View File
@@ -59,6 +59,35 @@ fn secret_debug_is_masked() {
assert!(Secret::new(" ").is_empty()); assert!(Secret::new(" ").is_empty());
} }
/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。
///
/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒
/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。
///
/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。
#[test]
fn the_key_message_never_prints_the_key() {
use crate::pages::settings::ai_about::Message;
const SECRET: &str = "sk-live-MSG-LEAK-7c2f";
let message = Message::EditKey(Secret::new(SECRET));
let printed = format!("{message:?}");
assert!(
!printed.contains(SECRET),
"页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}"
);
assert!(printed.contains("***"), "应当看得见掩码标记:{printed}");
// 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。
let page =
crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message));
let printed = format!("{page:?}");
assert!(
!printed.contains(SECRET),
"页面消息漏斗泄漏了密钥:{printed}"
);
}
#[test] #[test]
fn validate_refuses_bad_endpoint_and_empty_model() { fn validate_refuses_bad_endpoint_and_empty_model() {
let mut state = configured(); let mut state = configured();
@@ -217,59 +246,80 @@ fn saving_a_new_target_drops_the_old_pass() {
assert_eq!(state.status(), Status::Configured); assert_eq!(state.status(), Status::Configured);
} }
/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。
///
/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷——
/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。
#[test] #[test]
fn not_configured_outcome_carries_no_target() { fn failures_carry_the_target_that_was_dialed() {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome}; use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target};
let result = ProbeResult::from_outcome( let target = || Target {
base_url: "https://api.deepseek.com/v1".to_string(),
model: "deepseek-chat".to_string(),
};
let not_configured = ProbeResult::from_outcome(
ProbeOutcome::NotConfigured { ProbeOutcome::NotConfigured {
missing: Missing::Model, missing: Missing::Model,
}, },
0, 0,
); );
assert_eq!(result.kind, ProbeKind::NotConfigured); assert_eq!(not_configured.kind, ProbeKind::NotConfigured);
assert_eq!(result.missing, Some(MissingField::Model)); assert_eq!(not_configured.missing, Some(MissingField::Model));
assert!(result.base_url.is_empty(), "没发请求就没有目标地址"); assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址");
assert!(result.model.is_empty()); assert!(not_configured.model.is_empty());
assert!(!result.sent_a_request()); assert!(!not_configured.sent_a_request());
// 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。
for (outcome, expected) in [ for (outcome, expected) in [
( (
ProbeOutcome::Auth { ProbeOutcome::Auth {
target: target(),
detail: "401".to_string(), detail: "401".to_string(),
}, },
ProbeKind::Auth, ProbeKind::Auth,
), ),
( (
ProbeOutcome::ModelNotFound { ProbeOutcome::ModelNotFound {
target: target(),
detail: "404".to_string(), detail: "404".to_string(),
}, },
ProbeKind::ModelNotFound, ProbeKind::ModelNotFound,
), ),
( (
ProbeOutcome::Unreachable { ProbeOutcome::Unreachable {
target: target(),
detail: "refused".to_string(), detail: "refused".to_string(),
}, },
ProbeKind::Unreachable, ProbeKind::Unreachable,
), ),
( (
ProbeOutcome::Timeout { ProbeOutcome::Timeout {
target: target(),
detail: "8 s".to_string(), detail: "8 s".to_string(),
}, },
ProbeKind::Timeout, ProbeKind::Timeout,
), ),
( (
ProbeOutcome::Failed { ProbeOutcome::Failed {
target: target(),
detail: "?".to_string(), detail: "?".to_string(),
}, },
ProbeKind::Failed, ProbeKind::Failed,
), ),
(ProbeOutcome::Passed { target: target() }, ProbeKind::Passed),
] { ] {
let result = ProbeResult::from_outcome(outcome, 5); let result = ProbeResult::from_outcome(outcome, 5);
assert_eq!(result.kind, expected); assert_eq!(result.kind, expected);
assert_eq!(
result.base_url, "https://api.deepseek.com/v1",
"{expected:?}"
);
assert_eq!(result.model, "deepseek-chat", "{expected:?}");
if expected != ProbeKind::Passed {
assert!(result.sent_a_request()); assert!(result.sent_a_request());
} }
}
} }
#[test] #[test]
@@ -288,6 +338,15 @@ fn screenshot_seed_covers_the_state_matrix() {
let failed = State::screenshot_seed("failed-auth").expect("种子"); let failed = State::screenshot_seed("failed-auth").expect("种子");
assert_eq!(failed.status(), Status::Failed); assert_eq!(failed.status(), Status::Failed);
// P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。
// 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。
if let Probe::Done(result) = &failed.probe {
assert_eq!(result.base_url, "https://api.deepseek.com/v1");
assert_eq!(result.model, "deepseek-chat");
assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情");
} else {
panic!("失败种子没带上探测结论");
}
assert!( assert!(
State::screenshot_seed("nonsense").is_none(), State::screenshot_seed("nonsense").is_none(),
"未知种子不伪造状态" "未知种子不伪造状态"
@@ -27,7 +27,7 @@ use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch}; use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::{card, divider, text_role_owned}; use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::settings::ai::outcome::ProbeKind; use crate::pages::settings::ai::outcome::ProbeKind;
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, State, Status}; use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
use crate::pages::settings::{ai_about, GroupMessage, States}; use crate::pages::settings::{ai_about, GroupMessage, States};
use crate::pages::Message; use crate::pages::Message;
use crate::theme::Ui; use crate::theme::Ui;
@@ -64,6 +64,9 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
} }
if let Probe::Done(result) = &state.probe { if let Probe::Done(result) = &state.probe {
stacked = stacked.push(probe_line(ui, catalog, result.kind)); stacked = stacked.push(probe_line(ui, catalog, result.kind));
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
if !(result.base_url.is_empty() && result.model.is_empty()) {
stacked = stacked.push(text_role_owned( stacked = stacked.push(text_role_owned(
ui, ui,
TypeRole::BodySmall, TypeRole::BodySmall,
@@ -78,6 +81,7 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
), ),
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
)); ));
}
if !result.detail.trim().is_empty() { if !result.detail.trim().is_empty() {
stacked = stacked.push(text_role_owned( stacked = stacked.push(text_role_owned(
ui, ui,
@@ -240,7 +244,7 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a,
), ),
); );
let field = container(text_field(ui, field, |value| { let field = container(text_field(ui, field, |value| {
msg(ai_about::Message::EditKey(value)) msg(ai_about::Message::EditKey(Secret::new(value)))
})) }))
.width(Length::Fill); .width(Length::Fill);
column![ column![
@@ -54,8 +54,12 @@ pub enum Message {
EditBaseUrl(String), EditBaseUrl(String),
/// 模型名草稿(`ai.model`) /// 模型名草稿(`ai.model`)
EditModel(String), EditModel(String),
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把) /// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。
EditKey(String), ///
/// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness
/// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里,
/// 用裸 `String` 等于给密钥开了一条到日志的路。
EditKey(super::ai::Secret),
/// 密钥输入框明文/掩码 /// 密钥输入框明文/掩码
Reveal(bool), Reveal(bool),
/// 保存(校验 + 落盘) /// 保存(校验 + 落盘)
+89 -82
View File
@@ -1,18 +1,20 @@
//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现** //! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现**
//! ([`RigProvider`])发一次最小请求,并把失败分成四类。 //! ([`RigProvider`])发一次最小请求,并把失败分成四类。
//! //!
//! ## 为什么分类不在 provider 层 //! ## 分类从哪来(r1 复审 P2-5 之后)
//! //!
//! `brain::rig` 把 rig 的错误压成一句 `Display` 文本(`ProviderError::new(error.to_string())`), //! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化
//! 那里拿不到结构化的状态码;本模块是**唯一**把「错误文本」翻译成人能看懂的四类的地方, //! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成
//! 四类各自有一条真 TCP 假服务器用例钉住(`probe/tests.rs`),谁都不许靠猜。 //! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射:
//! //!
//! | 类 | 判据(错误文本里) | 用户看到的 | //! | [`ProviderFailure`] | 本模块产出 |
//! |---|---|---| //! |---|---|
//! | [`ProbeOutcome::Auth`] | `401` / `403` / `unauthorized` / `invalid_api_key` | 密钥不对(或没有权限) | //! | `Auth`(401/403) | [`ProbeOutcome::Auth`] |
//! | [`ProbeOutcome::ModelNotFound`] | `404` / `model_not_found` / `does not exist` | 端点通了,但没有这个模型名 | //! | `ModelNotFound`(404 / `model_not_found`) | [`ProbeOutcome::ModelNotFound`] |
//! | [`ProbeOutcome::Unreachable`] | 连接被拒 / DNS / TLS / `error sending request` | 端点不可达 | //! | `Unreachable`(传输层:连接被拒 / DNS / TLS) | [`ProbeOutcome::Unreachable`] |
//! | [`ProbeOutcome::Timeout`] | 超时 | 超时(网络太慢或端点不响应) | //! | `Timeout`(8 秒时限 / 传输层超时) | [`ProbeOutcome::Timeout`] |
//! | `NotConfigured`(没发请求) | [`ProbeOutcome::NotConfigured`] |
//! | `Other`(解析 / 5xx / 未知) | [`ProbeOutcome::Failed`] |
//! //!
//! ## 三条硬纪律 //! ## 三条硬纪律
//! //!
@@ -20,14 +22,19 @@
//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。 //! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。
//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把 //! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把
//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。 //! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),`ProbeOutcome` 里没有密钥字段。 //! [`AiSettings`] 的 `Debug` 是手写的(掩码),[`ProbeOutcome`] 里没有密钥字段。
//! 3. **读的是文件里现在那一份**:[`probe_file`] 每次现读,所以「设置页保存完再测」测的就是 //! 3. **每次现读文件**:[`probe_file`] 读的是「现在这一份」,所以设置页保存完再测,测的就是刚
//! 刚存下的 `ai.base_url`——不是启动时缓存的旧值。 //! 存下的 `ai.base_url`(不是启动时缓存的旧值)。
//!
//! 失败结论**一律带上被请求的 target**(r1 复审 P2-1):只说「鉴权失败」而不说「对哪个端点、
//! 哪个模型」,用户改完 key 也没法确认自己改的是不是那一份配置。
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use unlucky_agent::brain::provider::{ProviderConfig, ProviderError, ProviderKind}; use unlucky_agent::brain::provider::{
ProviderConfig, ProviderError, ProviderFailure, ProviderKind,
};
use unlucky_agent::brain::rig::RigProvider; use unlucky_agent::brain::rig::RigProvider;
use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory}; use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory};
use unlucky_agent::redact::{redact_text, MASK}; use unlucky_agent::redact::{redact_text, MASK};
@@ -36,17 +43,15 @@ use unlucky_core::setting::store::SettingStore;
/// 一次「测试连接」的默认时限。 /// 一次「测试连接」的默认时限。
/// ///
/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应(UI 侧有「正在测试」 /// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应。
/// 态,但那不是让人等半分钟的理由)。
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8); pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
/// 探测请求的模型名之外只发一句话:够触发一次真实补全,又不烧 token。 /// 探测请求只发这一句:够触发一次真实补全,又不烧 token。
const PING: &str = "ping"; const PING: &str = "ping";
/// 从设置文件读出来的 AI 四项(快照)。 /// 从设置文件读出来的 AI 四项(快照)。
/// ///
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码——与 /// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。
/// `ProviderConfig` 同一条纪律:`{:?}` 是日志/panic 消息的默认出口。
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub struct AiSettings { pub struct AiSettings {
/// `ai.enabled` /// `ai.enabled`
@@ -102,43 +107,60 @@ impl Missing {
} }
} }
/// 被请求的那一份配置(端点 + 模型):失败结论也要带它,用户才知道「改的是不是这一份」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// 实际请求的端点(`resolved_base_url`)。
pub base_url: String,
/// 实际请求的模型名。
pub model: String,
}
/// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。 /// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome { pub enum ProbeOutcome {
/// 配置不完整:**没有发出任何请求**。 /// 配置不完整:**没有发出任何请求**(因此也没有 target)。
NotConfigured { NotConfigured {
/// 缺的是哪一项。 /// 缺的是哪一项。
missing: Missing, missing: Missing,
}, },
/// 通了(拿到一次真实补全的回复)。 /// 通了(拿到一次真实补全的回复)。
Passed { Passed {
/// 实际请求的端点(`resolved_base_url`)。 /// 实际请求的目标。
base_url: String, target: Target,
/// 实际请求的模型名。
model: String,
}, },
/// 鉴权失败(401 / 403 / `invalid_api_key`)。 /// 鉴权失败(401 / 403)。
Auth { Auth {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。 /// 脱敏后的原因。
detail: String, detail: String,
}, },
/// 模型不存在(404 / `model_not_found`)。 /// 模型不存在(404 / `model_not_found`)。
ModelNotFound { ModelNotFound {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。 /// 脱敏后的原因。
detail: String, detail: String,
}, },
/// 端点不可达(连接被拒 / DNS / TLS)。 /// 端点不可达(连接被拒 / DNS / TLS)。
Unreachable { Unreachable {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。 /// 脱敏后的原因。
detail: String, detail: String,
}, },
/// 超时。 /// 超时。
Timeout { Timeout {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因(含时限)。 /// 脱敏后的原因(含时限)。
detail: String, detail: String,
}, },
/// 其它(协议/解析/未知 HTTP 码):不硬塞进上面四类。 /// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。
Failed { Failed {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。 /// 脱敏后的原因。
detail: String, detail: String,
}, },
@@ -151,7 +173,8 @@ impl ProbeOutcome {
matches!(self, Self::Passed { .. }) matches!(self, Self::Passed { .. })
} }
/// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` / `not_configured` / `failed`)。 /// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` /
/// `not_configured` / `failed`)。
#[must_use] #[must_use]
pub const fn kind(&self) -> &'static str { pub const fn kind(&self) -> &'static str {
match self { match self {
@@ -164,44 +187,19 @@ impl ProbeOutcome {
Self::Failed { .. } => "failed", Self::Failed { .. } => "failed",
} }
} }
}
/// 错误文本 → 四类。**顺序有讲究**:先超时(超时文本里可能带 URL 里的 `401` 之类数字), /// 这条结论是关于哪个端点/模型的(`NotConfigured` 没有)。
/// 再鉴权,再模型,最后不可达。 #[must_use]
#[must_use] pub fn target(&self) -> Option<&Target> {
pub fn classify(message: &str) -> &'static str { match self {
let text = message.to_lowercase(); Self::NotConfigured { .. } => None,
let has = |needle: &str| text.contains(needle); Self::Passed { target }
if has("timed out") || has("timeout") || has("deadline exceeded") { | Self::Auth { target, .. }
"timeout" | Self::ModelNotFound { target, .. }
} else if has("401") | Self::Unreachable { target, .. }
|| has("403") | Self::Timeout { target, .. }
|| has("unauthorized") | Self::Failed { target, .. } => Some(target),
|| 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"
} }
} }
@@ -218,10 +216,7 @@ impl AiSettings {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(str::to_string); .map(str::to_string);
Self { Self {
enabled: doc enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
.effective("ai.enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
provider: text("ai.provider"), provider: text("ai.provider"),
base_url: text("ai.base_url"), base_url: text("ai.base_url"),
model: text("ai.model"), model: text("ai.model"),
@@ -298,16 +293,18 @@ pub fn probe_with(
Ok(config) => config, Ok(config) => config,
Err(missing) => return ProbeOutcome::NotConfigured { missing }, Err(missing) => return ProbeOutcome::NotConfigured { missing },
}; };
let base_url = config.resolved_base_url().to_string(); let target = Target {
let model = config.model.clone(); base_url: config.resolved_base_url().to_string(),
model: config.model.clone(),
};
let provider = match factory(&config) { let provider = match factory(&config) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => return outcome_from_error(&error, settings), Err(error) => return outcome_from_error(&error, settings, target),
}; };
let request = ChatRequest { let request = ChatRequest {
model: model.clone(), model: target.model.clone(),
messages: vec![ChatMessage::user(PING)], messages: vec![ChatMessage::user(PING)],
tools: Vec::new(), tools: Vec::new(),
temperature: Some(0.0), temperature: Some(0.0),
@@ -316,24 +313,34 @@ pub fn probe_with(
unlucky_core::download::blocking::block_on(async move { unlucky_core::download::blocking::block_on(async move {
match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await { match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await {
Ok(Ok(_reply)) => ProbeOutcome::Passed { base_url, model }, Ok(Ok(_reply)) => ProbeOutcome::Passed { target },
Ok(Err(error)) => outcome_from_error(&error, settings), Ok(Err(error)) => outcome_from_error(&error, settings, target),
Err(_) => ProbeOutcome::Timeout { Err(_) => ProbeOutcome::Timeout {
target,
detail: format!("{} s", timeout.as_secs_f32()), detail: format!("{} s", timeout.as_secs_f32()),
}, },
} }
}) })
} }
/// provider 错误 → 分类结果(`detail` 先脱敏再出门)。 /// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。
fn outcome_from_error(error: &ProviderError, settings: &AiSettings) -> ProbeOutcome { fn outcome_from_error(
error: &ProviderError,
settings: &AiSettings,
target: Target,
) -> ProbeOutcome {
let detail = scrub(&error.to_string(), settings.secret()); let detail = scrub(&error.to_string(), settings.secret());
match classify(&error.to_string()) { match error.kind() {
"auth" => ProbeOutcome::Auth { detail }, ProviderFailure::Auth => ProbeOutcome::Auth { target, detail },
"model_not_found" => ProbeOutcome::ModelNotFound { detail }, ProviderFailure::ModelNotFound => ProbeOutcome::ModelNotFound { target, detail },
"unreachable" => ProbeOutcome::Unreachable { detail }, ProviderFailure::Unreachable => ProbeOutcome::Unreachable { target, detail },
"timeout" => ProbeOutcome::Timeout { detail }, ProviderFailure::Timeout => ProbeOutcome::Timeout { target, detail },
_ => ProbeOutcome::Failed { detail }, // 工厂阶段就报「配置不完整」(`validate()` 拒绝):仍然没发请求,但缺项已经在上游
// `to_config()` 拦过,这里退到 `Provider` 这个具名缺项。
ProviderFailure::NotConfigured => ProbeOutcome::NotConfigured {
missing: Missing::Provider,
},
ProviderFailure::Other => ProbeOutcome::Failed { target, detail },
} }
} }
+100 -28
View File
@@ -2,14 +2,13 @@
//! //!
//! | 用例 | 守什么 | 变异(改成什么会红) | //! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---| //! |---|---|---|
//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `classify` 删掉 `401` 分支 | //! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` |
//! | `missing_model_is_model_not_found` | 404/model_not_found → 模型不存在 | 把 `model_not_found` 归进 auth | //! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth |
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 删 `connection refused` 分支 | //! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` |
//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` | //! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` |
//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 | //! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 |
//! | `incomplete_config_never_builds_a_provider` | 未配置 ⇒ 工厂构造计数 **0** | 把校验挪到工厂调用之后 | //! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 |
//! | `saved_base_url_is_the_address_actually_dialed` | 改完设置再测,请求打到**新**地址 | 用启动时缓存的值 / 写死端点 | //! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) |
//! | `key_never_leaks_into_outcome_or_debug` | 密钥不进 detail / Debug | 删掉 `scrub` 的 replace |
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
@@ -28,10 +27,17 @@ enum Behaviour {
Silent, Silent,
} }
/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求头,按 `behaviour` 回。 /// 一条收到的请求:头 + 正文(P2-3:正文里才有 `model`)。
#[derive(Clone)]
struct Hit {
head: String,
body: String,
}
/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求,按 `behaviour` 回。
struct Server { struct Server {
port: u16, port: u16,
hits: Arc<Mutex<Vec<String>>>, hits: Arc<Mutex<Vec<Hit>>>,
} }
impl Server { impl Server {
@@ -53,27 +59,49 @@ impl Server {
format!("http://127.0.0.1:{}/v1", self.port) format!("http://127.0.0.1:{}/v1", self.port)
} }
fn hits(&self) -> Vec<String> { fn hits(&self) -> Vec<Hit> {
self.hits.lock().expect("锁").clone() self.hits.lock().expect("锁").clone()
} }
} }
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<String>>>) { fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<Hit>>>) {
let mut head = [0_u8; 4096]; let mut buffer = [0_u8; 8192];
let mut text = String::new(); let mut raw = Vec::new();
// 请求头到 `\r\n\r\n` 为止就够了:本模块只关心方法 + 路径(正文是 `ping`)。 // 先读头,再按 `Content-Length` 把正文读全(P2-3:只读头就断言不到 `model`)。
while !text.contains("\r\n\r\n") { let head_end = loop {
match stream.read(&mut head) { if let Some(at) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
Ok(0) => break, break at + 4;
Ok(n) => text.push_str(&String::from_utf8_lossy(&head[..n])), }
match stream.read(&mut buffer) {
Ok(0) => break raw.len(),
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => return, Err(_) => return,
} }
};
let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
let length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while raw.len() < head_end + length {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => break,
} }
sink.lock().expect("锁").push(text.clone()); }
let body =
String::from_utf8_lossy(&raw[head_end..raw.len().min(head_end + length)]).to_string();
sink.lock().expect("锁").push(Hit { head, body });
match behaviour { match behaviour {
Behaviour::Silent => { Behaviour::Silent => {
// 拖住这条连接(客户端会先超时):比客户端时限长得多,收尾时自己断开。 // 拖住这条连接(客户端会先超时):比客户端时限长得多。
std::thread::sleep(Duration::from_secs(5)); std::thread::sleep(Duration::from_secs(5));
} }
Behaviour::Reply(status, content_type, body) => { Behaviour::Reply(status, content_type, body) => {
@@ -120,11 +148,13 @@ fn auth_failure_is_classified_as_auth() {
&settings(&server.base_url(), "gpt-test"), &settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5), Duration::from_secs(5),
); );
let ProbeOutcome::Auth { detail } = &outcome else { let ProbeOutcome::Auth { target, detail } = &outcome else {
panic!("401 必须归到鉴权失败,实际 {outcome:?}"); panic!("401 必须归到鉴权失败,实际 {outcome:?}");
}; };
assert!(!detail.is_empty()); assert!(!detail.is_empty());
assert_eq!(outcome.kind(), "auth"); // P2-1:失败结论必须说清「对哪个端点 / 哪个模型」。
assert_eq!(target.base_url, server.base_url());
assert_eq!(target.model, "gpt-test");
// 请求确实发出去了(否则「鉴权失败」是凭空说的)。 // 请求确实发出去了(否则「鉴权失败」是凭空说的)。
assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过"); assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过");
} }
@@ -144,8 +174,9 @@ fn missing_model_is_model_not_found() {
assert_eq!( assert_eq!(
outcome.kind(), outcome.kind(),
"model_not_found", "model_not_found",
"404/model_not_found 必须归到模型不存在,实际 {outcome:?}" "404 必须归到模型不存在,实际 {outcome:?}"
); );
assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope"));
} }
#[test] #[test]
@@ -155,9 +186,10 @@ fn closed_port_is_unreachable() {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口"); let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口");
listener.local_addr().expect("地址").port() listener.local_addr().expect("地址").port()
}; };
let base_url = format!("http://127.0.0.1:{port}/v1");
let outcome = probe_with( let outcome = probe_with(
&RigProvider::factory(), &RigProvider::factory(),
&settings(&format!("http://127.0.0.1:{port}/v1"), "gpt-test"), &settings(&base_url, "gpt-test"),
Duration::from_secs(5), Duration::from_secs(5),
); );
assert_eq!( assert_eq!(
@@ -165,6 +197,10 @@ fn closed_port_is_unreachable() {
"unreachable", "unreachable",
"没人监听的端口必须归到端点不可达,实际 {outcome:?}" "没人监听的端口必须归到端点不可达,实际 {outcome:?}"
); );
assert_eq!(
outcome.target().map(|t| t.base_url.as_str()),
Some(base_url.as_str())
);
} }
#[test] #[test]
@@ -201,15 +237,51 @@ fn success_is_one_real_completion() {
Duration::from_secs(5), Duration::from_secs(5),
); );
match &outcome { match &outcome {
ProbeOutcome::Passed { base_url, model } => { ProbeOutcome::Passed { target } => {
assert_eq!(base_url, &server.base_url()); assert_eq!(target.base_url, server.base_url());
assert_eq!(model, "gpt-test"); assert_eq!(target.model, "gpt-test");
} }
other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"), other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"),
} }
} }
// 不发请求那一半的守卫(未配置不建 provider / 改完设置打新地址 / 密钥不外流)在 /// **P2-3**:请求体里的 `model` 必须是用户配置的那个(不是写死的)。
// `probe/tests_guards.rs`:本文件只放真 TCP 假服务器那五条与密钥掩码那一条。 ///
/// 变异:把 `probe_with` 的 `model: target.model.clone()` 改成 `"gpt-4o".to_string()` ⇒ 本用例
/// 断言 `\"model\":\"gpt-test\"` 失败。这是「配置里的模型真的上了线」唯一可信的证据:只看
/// 状态码通过并不能证明服务端收到的是哪个模型。
#[test]
fn the_request_carries_the_configured_model() {
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),
);
assert!(outcome.is_pass(), "前提:这一轮应当通过,实际 {outcome:?}");
let hits = server.hits();
assert_eq!(hits.len(), 1, "应当恰好一次请求");
let hit = &hits[0];
assert!(
hit.head.starts_with("POST /v1/chat/completions"),
"请求行不对:{}",
hit.head.lines().next().unwrap_or("")
);
assert!(
!hit.body.is_empty(),
"没有读到请求正文(夹具没按 Content-Length 读 body?)"
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
}
#[path = "tests_guards.rs"] #[path = "tests_guards.rs"]
mod guards; mod guards;
@@ -9,7 +9,7 @@ use std::time::Duration;
use unlucky_agent::brain::provider::ProviderConfig; use unlucky_agent::brain::provider::ProviderConfig;
use unlucky_agent::brain::{ use unlucky_agent::brain::{
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderError, ProviderFuture, ChatProvider, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
}; };
use unlucky_agent::redact::MASK; use unlucky_agent::redact::MASK;
@@ -140,15 +140,22 @@ fn saved_base_url_is_the_address_actually_dialed() {
assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次"); assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次");
let hit = &new.hits()[0]; let hit = &new.hits()[0];
assert!( assert!(
hit.contains("POST /v1/chat/completions"), hit.head.starts_with("POST /v1/chat/completions"),
"OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}", "OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}",
hit.lines().next().unwrap_or("") hit.head.lines().next().unwrap_or("")
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
// P2-1:改完 base_url 之后,失败结论里说的也必须是**新**地址。
assert_eq!(
second.target().map(|target| target.base_url.as_str()),
Some(new.base_url().as_str())
); );
} }
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。 /// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
/// ///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。 /// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
@@ -242,34 +249,42 @@ fn write_config(path: &PathBuf, body: &str) {
std::fs::write(path, body).expect("写配置文件"); std::fs::write(path, body).expect("写配置文件");
} }
/// 让编译器确认 `ChatReply` / `ProviderError` 的导入是被用的(构造一个不可能的 provider /// 工厂阶段的错误也按**结构化分类**走(r1 复审 P2-5 之后:这里不再看文本)。
/// 用到它们),同时钉住「工厂返回错误也要分类」这条支路。 ///
/// 两条一起钉住:
/// 1. 工厂给的是 `classified(Auth, …)` ⇒ 结论就是 `auth`(分类信息不丢);
/// 2. 工厂给的是 `ProviderError::new("401 bad key")`(**纯文本、没有分类**)⇒ 结论是 `failed`,
/// **不是** `auth`。第二条是 P2-5 的绊线:谁把「按 `"401"` 子串猜类别」加回来,这条立刻红。
#[test] #[test]
fn factory_error_is_classified_too() { fn factory_error_carries_the_structured_classification() {
struct Broken; let classified: unlucky_agent::brain::ProviderFactory = Arc::new(|_config: &ProviderConfig| {
impl ChatProvider for Broken { Err(ProviderError::classified(
fn config(&self) -> &ProviderConfig { unlucky_agent::brain::ProviderFailure::Auth,
unreachable!() "401 Unauthorized",
} ))
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( let outcome = probe_with(
&factory, &classified,
&settings("http://127.0.0.1:1/v1", "gpt-test"), &settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50), Duration::from_millis(50),
); );
assert_eq!( assert_eq!(
outcome.kind(), outcome.kind(),
"auth", "auth",
"工厂阶段的错误也要分类:{outcome:?}" "工厂阶段的结构化分类不许丢:{outcome:?}"
);
// 纯文本错误(无分类)**不许**被猜成鉴权失败。
let text_only: unlucky_agent::brain::ProviderFactory =
Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key")));
let outcome = probe_with(
&text_only,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"failed",
"没有结构化分类的错误必须落在「其它」,不许按文本猜成 auth(P2-5):{outcome:?}"
); );
} }
+45
View File
@@ -120,10 +120,19 @@ impl SettingsBridge {
} }
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
///
/// **写前先 `reload()`**(M174 r1 复审 P2-4):本桥的 store 是长生命的(启动时 load 一次),
/// 而 `SettingStore::save` 是**整份文档**落盘(`core/src/setting/store.rs` 的 `write_document`
/// 序列化 `doc.to_value()`,不是只写脏键)。不先重读就会拿「≤1 秒前的旧快照」整体覆盖文件:
/// 用户刚在设置页存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒被外观写回抹掉
/// (R1 复审给的是这个现场:保存 AI 配置后 1 秒内改一次主题,密钥连影子都不剩)。
///
/// 与 `HostEnv::store()` 的口径对齐:**写之前现读**,不信任任何长生命快照。
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
let Some(store) = self.store.as_mut() else { let Some(store) = self.store.as_mut() else {
return; return;
}; };
store.reload();
let mut snapshot = Appearance::from_store(store); let mut snapshot = Appearance::from_store(store);
snapshot.theme_mode = theme.as_str().to_owned(); snapshot.theme_mode = theme.as_str().to_owned();
snapshot.bg_image_enabled = appearance.bg_image_enabled; snapshot.bg_image_enabled = appearance.bg_image_enabled;
@@ -288,6 +297,42 @@ mod tests {
panic!("外部修改没有被 mtime 热切捕获"); panic!("外部修改没有被 mtime 热切捕获");
} }
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
#[test]
fn persist_keeps_keys_another_writer_just_saved() {
let path = temp_config("persist-keeps-ai");
let mut bridge = SettingsBridge::with_path(&path);
// 先让本桥真的加载过一份文件(旧快照从这里来)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
{
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
let mut store = SettingStore::load(&path, launcher_schema());
for (key, value) in [
("ai.base_url", "https://api.deepseek.com/v1"),
("ai.model", "deepseek-chat"),
("ai.api_key", "sk-must-survive"),
] {
store.set(key, value.to_string().into()).expect("写 AI 键");
}
store.save().expect("落盘");
}
// 同一秒内改主题:外观写回。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"外观写回把刚保存的密钥盖掉了:{text}"
);
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test] #[test]
fn accent_preset_and_validation() { fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory(); let mut appearance = AppearancePrefs::factory();