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,
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 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::{
|
||||
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
|
||||
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
|
||||
@@ -64,7 +64,7 @@ use super::chat::{
|
||||
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
|
||||
ProviderFuture,
|
||||
};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
|
||||
|
||||
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
|
||||
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 {
|
||||
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`](空名是唯一的非法输入)。
|
||||
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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("对话历史为空"));
|
||||
}
|
||||
}
|
||||
#[path = "rig/tests.rs"]
|
||||
mod tests;
|
||||
@@ -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("对话历史为空"));
|
||||
}
|
||||
Reference in new issue
Block a user