Files
Unlucky-Launcher/crates/agent/src/brain/provider.rs
T
AskaEth 93d02ab12b 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`(纯搬移)。
2026-11-08 16:20:34 +08:00

325 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Provider 四态端点配置(02 文档 §2)。
//!
//! | 形态 | 端点 | 备注 |
//! |------|------|------|
//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio |
//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) |
//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 |
//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 |
//!
//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面
//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。
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)。
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434";
/// Anthropic 官方 API 端点。
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
/// Provider 四态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
/// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。
OpenAiCompatible,
/// Anthropic 原生。
Anthropic,
/// Ollama 原生。
Ollama,
/// 完全禁用 agent。
Disabled,
}
impl ProviderKind {
/// 该形态是否必须提供 API key(本地推理通常不需要)。
#[must_use]
pub fn requires_api_key(self) -> bool {
matches!(self, Self::Anthropic)
}
/// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填;
/// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。
#[must_use]
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL,
Self::Ollama => OLLAMA_DEFAULT_BASE_URL,
Self::Disabled => "",
}
}
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAiCompatible => "OpenAI 兼容",
Self::Anthropic => "Anthropic 原生",
Self::Ollama => "Ollama 原生",
Self::Disabled => "已禁用",
}
}
}
/// Provider 配置(设置 → AI 的一份快照)。
///
/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**——
/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderConfig {
/// 四态。
pub kind: ProviderKind,
/// 端点;空串表示用 [`ProviderKind::default_base_url`]。
pub base_url: String,
/// 模型名;`Disabled` 时可为空。
pub model: String,
/// API key(本地推理可空)。
pub api_key: Option<String>,
/// 采样温度。
pub temperature: Option<f32>,
/// 单次回复 token 上限。
pub max_tokens: Option<u32>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self::disabled()
}
}
impl ProviderConfig {
fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self {
Self {
kind,
base_url: base_url.to_string(),
model: model.to_string(),
api_key: None,
temperature: None,
max_tokens: None,
}
}
/// OpenAI 兼容端点(云端或自建)。
#[must_use]
pub fn openai_compatible(base_url: &str, model: &str) -> Self {
Self::base(ProviderKind::OpenAiCompatible, base_url, model)
}
/// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。
#[must_use]
pub fn llamacpp_local(model: &str) -> Self {
Self::base(
ProviderKind::OpenAiCompatible,
LLAMACPP_DEFAULT_BASE_URL,
model,
)
}
/// Anthropic 原生。
#[must_use]
pub fn anthropic(model: &str) -> Self {
Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model)
}
/// 本机 Ollama。
#[must_use]
pub fn ollama(model: &str) -> Self {
Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model)
}
/// 禁用 agent。
#[must_use]
pub fn disabled() -> Self {
Self::base(ProviderKind::Disabled, "", "")
}
/// 带上 API key。
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
/// 带上温度。
#[must_use]
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
/// agent 是否启用。
#[must_use]
pub fn is_enabled(&self) -> bool {
self.kind != ProviderKind::Disabled
}
/// 实际使用的端点(空则回落默认值)。
#[must_use]
pub fn resolved_base_url(&self) -> &str {
if self.base_url.trim().is_empty() {
self.kind.default_base_url()
} else {
self.base_url.trim()
}
}
/// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。
///
/// # Errors
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
));
}
if self.kind.requires_api_key()
&& self
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要 API key", self.kind.display_name()),
));
}
Ok(())
}
/// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。
#[must_use]
pub fn redacted_debug(&self) -> String {
let key = match self.api_key.as_deref() {
None => "无".to_string(),
Some(k) if k.trim().is_empty() => "无".to_string(),
Some(_) => crate::redact::MASK.to_string(),
};
format!(
"{} base_url={} model={} api_key={key}",
self.kind.display_name(),
self.resolved_base_url(),
if self.model.is_empty() {
"(未设置)"
} else {
&self.model
},
)
}
}
impl fmt::Debug for ProviderConfig {
/// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("base_url", &self.resolved_base_url())
.field("model", &self.model)
.field(
"api_key",
&self.api_key.as_deref().map(|_| crate::redact::MASK),
)
.field("temperature", &self.temperature)
.field("max_tokens", &self.max_tokens)
.finish()
}
}
/// 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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProviderError {}
#[cfg(test)]
#[path = "provider/tests.rs"]
mod tests;