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
@@ -14,6 +14,11 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[path = "provider/failure.rs"]
|
||||
mod failure;
|
||||
|
||||
pub use failure::ProviderFailure;
|
||||
|
||||
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
|
||||
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
|
||||
/// Ollama 默认端点(research/02 §5.2)。
|
||||
@@ -203,18 +208,22 @@ impl ProviderConfig {
|
||||
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
|
||||
pub fn validate(&self) -> Result<(), ProviderError> {
|
||||
if !self.is_enabled() {
|
||||
return Err(ProviderError::new(
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
|
||||
));
|
||||
}
|
||||
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() {
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要填写端点(base_url)",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
|
||||
));
|
||||
}
|
||||
if self.kind.requires_api_key()
|
||||
&& self
|
||||
@@ -222,10 +231,10 @@ impl ProviderConfig {
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
{
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要 API key",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要 API key", self.kind.display_name()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -268,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
|
||||
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
|
||||
/// `ErrorReport` 翻译,见 [`failure`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderError {
|
||||
kind: ProviderFailure,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
/// 从可读文本构造。
|
||||
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ProviderFailure::Other,
|
||||
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 {
|
||||
@@ -293,108 +320,5 @@ impl fmt::Display for ProviderError {
|
||||
impl std::error::Error for ProviderError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "provider/tests.rs"]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user