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:
1 parent
9cb605f316
commit
93d02ab12b
18 files changed
+840
-412
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("对话历史为空"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,58 +246,79 @@ 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!(result.sent_a_request());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,20 +64,24 @@ 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));
|
||||||
stacked = stacked.push(text_role_owned(
|
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
|
||||||
ui,
|
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
|
||||||
TypeRole::BodySmall,
|
if !(result.base_url.is_empty() && result.model.is_empty()) {
|
||||||
false,
|
stacked = stacked.push(text_role_owned(
|
||||||
catalog.render(
|
ui,
|
||||||
Key::SettingsAiTestTarget,
|
TypeRole::BodySmall,
|
||||||
&[
|
false,
|
||||||
("base_url", result.base_url.as_str()),
|
catalog.render(
|
||||||
("model", result.model.as_str()),
|
Key::SettingsAiTestTarget,
|
||||||
("ms", result.elapsed_ms.to_string().as_str()),
|
&[
|
||||||
],
|
("base_url", result.base_url.as_str()),
|
||||||
),
|
("model", result.model.as_str()),
|
||||||
ui.color(Role::OnSurfaceVariant),
|
("ms", result.elapsed_ms.to_string().as_str()),
|
||||||
));
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
/// 保存(校验 + 落盘)
|
/// 保存(校验 + 落盘)
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user