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
@@ -1,18 +1,20 @@
|
||||
//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现**
|
||||
//! ([`RigProvider`])发一次最小请求,并把失败分成四类。
|
||||
//!
|
||||
//! ## 为什么分类不在 provider 层
|
||||
//! ## 分类从哪来(r1 复审 P2-5 之后)
|
||||
//!
|
||||
//! `brain::rig` 把 rig 的错误压成一句 `Display` 文本(`ProviderError::new(error.to_string())`),
|
||||
//! 那里拿不到结构化的状态码;本模块是**唯一**把「错误文本」翻译成人能看懂的四类的地方,
|
||||
//! 四类各自有一条真 TCP 假服务器用例钉住(`probe/tests.rs`),谁都不许靠猜。
|
||||
//! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化
|
||||
//! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成
|
||||
//! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射:
|
||||
//!
|
||||
//! | 类 | 判据(错误文本里) | 用户看到的 |
|
||||
//! |---|---|---|
|
||||
//! | [`ProbeOutcome::Auth`] | `401` / `403` / `unauthorized` / `invalid_api_key` | 密钥不对(或没有权限) |
|
||||
//! | [`ProbeOutcome::ModelNotFound`] | `404` / `model_not_found` / `does not exist` | 端点通了,但没有这个模型名 |
|
||||
//! | [`ProbeOutcome::Unreachable`] | 连接被拒 / DNS / TLS / `error sending request` | 端点不可达 |
|
||||
//! | [`ProbeOutcome::Timeout`] | 超时 | 超时(网络太慢或端点不响应) |
|
||||
//! | [`ProviderFailure`] | 本模块产出 |
|
||||
//! |---|---|
|
||||
//! | `Auth`(401/403) | [`ProbeOutcome::Auth`] |
|
||||
//! | `ModelNotFound`(404 / `model_not_found`) | [`ProbeOutcome::ModelNotFound`] |
|
||||
//! | `Unreachable`(传输层:连接被拒 / DNS / TLS) | [`ProbeOutcome::Unreachable`] |
|
||||
//! | `Timeout`(8 秒时限 / 传输层超时) | [`ProbeOutcome::Timeout`] |
|
||||
//! | `NotConfigured`(没发请求) | [`ProbeOutcome::NotConfigured`] |
|
||||
//! | `Other`(解析 / 5xx / 未知) | [`ProbeOutcome::Failed`] |
|
||||
//!
|
||||
//! ## 三条硬纪律
|
||||
//!
|
||||
@@ -20,14 +22,19 @@
|
||||
//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。
|
||||
//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把
|
||||
//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。
|
||||
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),`ProbeOutcome` 里没有密钥字段。
|
||||
//! 3. **读的是文件里现在那一份**:[`probe_file`] 每次现读,所以「设置页保存完再测」测的就是
|
||||
//! 刚存下的 `ai.base_url`——不是启动时缓存的旧值。
|
||||
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),[`ProbeOutcome`] 里没有密钥字段。
|
||||
//! 3. **每次现读文件**:[`probe_file`] 读的是「现在这一份」,所以设置页保存完再测,测的就是刚
|
||||
//! 存下的 `ai.base_url`(不是启动时缓存的旧值)。
|
||||
//!
|
||||
//! 失败结论**一律带上被请求的 target**(r1 复审 P2-1):只说「鉴权失败」而不说「对哪个端点、
|
||||
//! 哪个模型」,用户改完 key 也没法确认自己改的是不是那一份配置。
|
||||
|
||||
use std::path::Path;
|
||||
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::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory};
|
||||
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);
|
||||
|
||||
/// 探测请求的模型名之外只发一句话:够触发一次真实补全,又不烧 token。
|
||||
/// 探测请求只发这一句:够触发一次真实补全,又不烧 token。
|
||||
const PING: &str = "ping";
|
||||
|
||||
/// 从设置文件读出来的 AI 四项(快照)。
|
||||
///
|
||||
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码——与
|
||||
/// `ProviderConfig` 同一条纪律:`{:?}` 是日志/panic 消息的默认出口。
|
||||
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AiSettings {
|
||||
/// `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` 已脱敏,可直接上屏/进日志)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProbeOutcome {
|
||||
/// 配置不完整:**没有发出任何请求**。
|
||||
/// 配置不完整:**没有发出任何请求**(因此也没有 target)。
|
||||
NotConfigured {
|
||||
/// 缺的是哪一项。
|
||||
missing: Missing,
|
||||
},
|
||||
/// 通了(拿到一次真实补全的回复)。
|
||||
Passed {
|
||||
/// 实际请求的端点(`resolved_base_url`)。
|
||||
base_url: String,
|
||||
/// 实际请求的模型名。
|
||||
model: String,
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
},
|
||||
/// 鉴权失败(401 / 403 / `invalid_api_key`)。
|
||||
/// 鉴权失败(401 / 403)。
|
||||
Auth {
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
/// 脱敏后的原因。
|
||||
detail: String,
|
||||
},
|
||||
/// 模型不存在(404 / `model_not_found`)。
|
||||
ModelNotFound {
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
/// 脱敏后的原因。
|
||||
detail: String,
|
||||
},
|
||||
/// 端点不可达(连接被拒 / DNS / TLS)。
|
||||
Unreachable {
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
/// 脱敏后的原因。
|
||||
detail: String,
|
||||
},
|
||||
/// 超时。
|
||||
Timeout {
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
/// 脱敏后的原因(含时限)。
|
||||
detail: String,
|
||||
},
|
||||
/// 其它(协议/解析/未知 HTTP 码):不硬塞进上面四类。
|
||||
/// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。
|
||||
Failed {
|
||||
/// 实际请求的目标。
|
||||
target: Target,
|
||||
/// 脱敏后的原因。
|
||||
detail: String,
|
||||
},
|
||||
@@ -151,7 +173,8 @@ impl ProbeOutcome {
|
||||
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]
|
||||
pub const fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -164,44 +187,19 @@ impl ProbeOutcome {
|
||||
Self::Failed { .. } => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误文本 → 四类。**顺序有讲究**:先超时(超时文本里可能带 URL 里的 `401` 之类数字),
|
||||
/// 再鉴权,再模型,最后不可达。
|
||||
#[must_use]
|
||||
pub fn classify(message: &str) -> &'static str {
|
||||
let text = message.to_lowercase();
|
||||
let has = |needle: &str| text.contains(needle);
|
||||
if has("timed out") || has("timeout") || has("deadline exceeded") {
|
||||
"timeout"
|
||||
} else if has("401")
|
||||
|| has("403")
|
||||
|| has("unauthorized")
|
||||
|| has("invalid_api_key")
|
||||
|| has("invalid api key")
|
||||
|| has("authentication")
|
||||
{
|
||||
"auth"
|
||||
} else if has("404")
|
||||
|| has("model_not_found")
|
||||
|| has("model not found")
|
||||
|| has("no such model")
|
||||
|| (has("model") && has("does not exist"))
|
||||
{
|
||||
"model_not_found"
|
||||
} else if has("connection refused")
|
||||
|| has("dns")
|
||||
|| has("failed to connect")
|
||||
|| has("connect error")
|
||||
|| has("error sending request")
|
||||
|| has("network is unreachable")
|
||||
|| has("unreachable")
|
||||
|| has("tls")
|
||||
|| has("certificate")
|
||||
{
|
||||
"unreachable"
|
||||
} else {
|
||||
"failed"
|
||||
/// 这条结论是关于哪个端点/模型的(`NotConfigured` 没有)。
|
||||
#[must_use]
|
||||
pub fn target(&self) -> Option<&Target> {
|
||||
match self {
|
||||
Self::NotConfigured { .. } => None,
|
||||
Self::Passed { target }
|
||||
| Self::Auth { target, .. }
|
||||
| Self::ModelNotFound { target, .. }
|
||||
| Self::Unreachable { target, .. }
|
||||
| Self::Timeout { target, .. }
|
||||
| Self::Failed { target, .. } => Some(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,10 +216,7 @@ impl AiSettings {
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
Self {
|
||||
enabled: doc
|
||||
.effective("ai.enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
|
||||
provider: text("ai.provider"),
|
||||
base_url: text("ai.base_url"),
|
||||
model: text("ai.model"),
|
||||
@@ -298,16 +293,18 @@ pub fn probe_with(
|
||||
Ok(config) => config,
|
||||
Err(missing) => return ProbeOutcome::NotConfigured { missing },
|
||||
};
|
||||
let base_url = config.resolved_base_url().to_string();
|
||||
let model = config.model.clone();
|
||||
let target = Target {
|
||||
base_url: config.resolved_base_url().to_string(),
|
||||
model: config.model.clone(),
|
||||
};
|
||||
|
||||
let provider = match factory(&config) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => return outcome_from_error(&error, settings),
|
||||
Err(error) => return outcome_from_error(&error, settings, target),
|
||||
};
|
||||
|
||||
let request = ChatRequest {
|
||||
model: model.clone(),
|
||||
model: target.model.clone(),
|
||||
messages: vec![ChatMessage::user(PING)],
|
||||
tools: Vec::new(),
|
||||
temperature: Some(0.0),
|
||||
@@ -316,24 +313,34 @@ pub fn probe_with(
|
||||
|
||||
unlucky_core::download::blocking::block_on(async move {
|
||||
match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await {
|
||||
Ok(Ok(_reply)) => ProbeOutcome::Passed { base_url, model },
|
||||
Ok(Err(error)) => outcome_from_error(&error, settings),
|
||||
Ok(Ok(_reply)) => ProbeOutcome::Passed { target },
|
||||
Ok(Err(error)) => outcome_from_error(&error, settings, target),
|
||||
Err(_) => ProbeOutcome::Timeout {
|
||||
target,
|
||||
detail: format!("{} s", timeout.as_secs_f32()),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// provider 错误 → 分类结果(`detail` 先脱敏再出门)。
|
||||
fn outcome_from_error(error: &ProviderError, settings: &AiSettings) -> ProbeOutcome {
|
||||
/// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。
|
||||
fn outcome_from_error(
|
||||
error: &ProviderError,
|
||||
settings: &AiSettings,
|
||||
target: Target,
|
||||
) -> ProbeOutcome {
|
||||
let detail = scrub(&error.to_string(), settings.secret());
|
||||
match classify(&error.to_string()) {
|
||||
"auth" => ProbeOutcome::Auth { detail },
|
||||
"model_not_found" => ProbeOutcome::ModelNotFound { detail },
|
||||
"unreachable" => ProbeOutcome::Unreachable { detail },
|
||||
"timeout" => ProbeOutcome::Timeout { detail },
|
||||
_ => ProbeOutcome::Failed { detail },
|
||||
match error.kind() {
|
||||
ProviderFailure::Auth => ProbeOutcome::Auth { target, detail },
|
||||
ProviderFailure::ModelNotFound => ProbeOutcome::ModelNotFound { target, detail },
|
||||
ProviderFailure::Unreachable => ProbeOutcome::Unreachable { target, detail },
|
||||
ProviderFailure::Timeout => ProbeOutcome::Timeout { target, 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` 分支 |
|
||||
//! | `missing_model_is_model_not_found` | 404/model_not_found → 模型不存在 | 把 `model_not_found` 归进 auth |
|
||||
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 删 `connection refused` 分支 |
|
||||
//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` |
|
||||
//! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth |
|
||||
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` |
|
||||
//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` |
|
||||
//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 |
|
||||
//! | `incomplete_config_never_builds_a_provider` | 未配置 ⇒ 工厂构造计数 **0** | 把校验挪到工厂调用之后 |
|
||||
//! | `saved_base_url_is_the_address_actually_dialed` | 改完设置再测,请求打到**新**地址 | 用启动时缓存的值 / 写死端点 |
|
||||
//! | `key_never_leaks_into_outcome_or_debug` | 密钥不进 detail / Debug | 删掉 `scrub` 的 replace |
|
||||
//! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 |
|
||||
//! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) |
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -28,10 +27,17 @@ enum Behaviour {
|
||||
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 {
|
||||
port: u16,
|
||||
hits: Arc<Mutex<Vec<String>>>,
|
||||
hits: Arc<Mutex<Vec<Hit>>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -53,27 +59,49 @@ impl Server {
|
||||
format!("http://127.0.0.1:{}/v1", self.port)
|
||||
}
|
||||
|
||||
fn hits(&self) -> Vec<String> {
|
||||
fn hits(&self) -> Vec<Hit> {
|
||||
self.hits.lock().expect("锁").clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<String>>>) {
|
||||
let mut head = [0_u8; 4096];
|
||||
let mut text = String::new();
|
||||
// 请求头到 `\r\n\r\n` 为止就够了:本模块只关心方法 + 路径(正文是 `ping`)。
|
||||
while !text.contains("\r\n\r\n") {
|
||||
match stream.read(&mut head) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => text.push_str(&String::from_utf8_lossy(&head[..n])),
|
||||
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<Hit>>>) {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let mut raw = Vec::new();
|
||||
// 先读头,再按 `Content-Length` 把正文读全(P2-3:只读头就断言不到 `model`)。
|
||||
let head_end = loop {
|
||||
if let Some(at) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
break at + 4;
|
||||
}
|
||||
match stream.read(&mut buffer) {
|
||||
Ok(0) => break raw.len(),
|
||||
Ok(n) => raw.extend_from_slice(&buffer[..n]),
|
||||
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 {
|
||||
Behaviour::Silent => {
|
||||
// 拖住这条连接(客户端会先超时):比客户端时限长得多,收尾时自己断开。
|
||||
// 拖住这条连接(客户端会先超时):比客户端时限长得多。
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
Behaviour::Reply(status, content_type, body) => {
|
||||
@@ -120,11 +148,13 @@ fn auth_failure_is_classified_as_auth() {
|
||||
&settings(&server.base_url(), "gpt-test"),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let ProbeOutcome::Auth { detail } = &outcome else {
|
||||
let ProbeOutcome::Auth { target, detail } = &outcome else {
|
||||
panic!("401 必须归到鉴权失败,实际 {outcome:?}");
|
||||
};
|
||||
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, "鉴权失败之前必须真的连上过");
|
||||
}
|
||||
@@ -144,8 +174,9 @@ fn missing_model_is_model_not_found() {
|
||||
assert_eq!(
|
||||
outcome.kind(),
|
||||
"model_not_found",
|
||||
"404/model_not_found 必须归到模型不存在,实际 {outcome:?}"
|
||||
"404 必须归到模型不存在,实际 {outcome:?}"
|
||||
);
|
||||
assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -155,9 +186,10 @@ fn closed_port_is_unreachable() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口");
|
||||
listener.local_addr().expect("地址").port()
|
||||
};
|
||||
let base_url = format!("http://127.0.0.1:{port}/v1");
|
||||
let outcome = probe_with(
|
||||
&RigProvider::factory(),
|
||||
&settings(&format!("http://127.0.0.1:{port}/v1"), "gpt-test"),
|
||||
&settings(&base_url, "gpt-test"),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -165,6 +197,10 @@ fn closed_port_is_unreachable() {
|
||||
"unreachable",
|
||||
"没人监听的端口必须归到端点不可达,实际 {outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.target().map(|t| t.base_url.as_str()),
|
||||
Some(base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,15 +237,51 @@ fn success_is_one_real_completion() {
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
match &outcome {
|
||||
ProbeOutcome::Passed { base_url, model } => {
|
||||
assert_eq!(base_url, &server.base_url());
|
||||
assert_eq!(model, "gpt-test");
|
||||
ProbeOutcome::Passed { target } => {
|
||||
assert_eq!(target.base_url, server.base_url());
|
||||
assert_eq!(target.model, "gpt-test");
|
||||
}
|
||||
other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 不发请求那一半的守卫(未配置不建 provider / 改完设置打新地址 / 密钥不外流)在
|
||||
// `probe/tests_guards.rs`:本文件只放真 TCP 假服务器那五条与密钥掩码那一条。
|
||||
/// **P2-3**:请求体里的 `model` 必须是用户配置的那个(不是写死的)。
|
||||
///
|
||||
/// 变异:把 `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"]
|
||||
mod guards;
|
||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
|
||||
use unlucky_agent::brain::provider::ProviderConfig;
|
||||
use unlucky_agent::brain::{
|
||||
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
|
||||
ChatProvider, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
|
||||
};
|
||||
use unlucky_agent::redact::MASK;
|
||||
|
||||
@@ -140,15 +140,22 @@ fn saved_base_url_is_the_address_actually_dialed() {
|
||||
assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次");
|
||||
let hit = &new.hits()[0];
|
||||
assert!(
|
||||
hit.contains("POST /v1/chat/completions"),
|
||||
hit.head.starts_with("POST /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` 里。
|
||||
///
|
||||
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
|
||||
@@ -242,34 +249,42 @@ fn write_config(path: &PathBuf, body: &str) {
|
||||
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]
|
||||
fn factory_error_is_classified_too() {
|
||||
struct Broken;
|
||||
impl ChatProvider for Broken {
|
||||
fn config(&self) -> &ProviderConfig {
|
||||
unreachable!()
|
||||
}
|
||||
fn complete<'a>(
|
||||
&'a self,
|
||||
_request: &'a ChatRequest,
|
||||
_observer: &'a dyn DeltaObserver,
|
||||
) -> ProviderFuture<'a> {
|
||||
Box::pin(async { Ok(ChatReply::default()) })
|
||||
}
|
||||
}
|
||||
let _ = Broken;
|
||||
let factory: unlucky_agent::brain::ProviderFactory =
|
||||
Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key")));
|
||||
fn factory_error_carries_the_structured_classification() {
|
||||
let classified: unlucky_agent::brain::ProviderFactory = Arc::new(|_config: &ProviderConfig| {
|
||||
Err(ProviderError::classified(
|
||||
unlucky_agent::brain::ProviderFailure::Auth,
|
||||
"401 Unauthorized",
|
||||
))
|
||||
});
|
||||
let outcome = probe_with(
|
||||
&factory,
|
||||
&classified,
|
||||
&settings("http://127.0.0.1:1/v1", "gpt-test"),
|
||||
Duration::from_millis(50),
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.kind(),
|
||||
"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:?}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user