--- url: https://ai-profile.ruoyi.plus/guide/introduction.md --- # 这是什么 ai-profile 是一个 Rust crate,为桌面应用提供「让用户配置 AI 模型服务」所需的那一层: 服务商预置清单、跨应用配置互通协议、端点拼接、连通性验证与结构化错误。 它**不是**对话 SDK —— 它不替你发起对话请求,不做流式解析。 它管的是**在用户点下「开始对话」之前**的所有事:选哪家、地址怎么拼、密钥对不对、模型名存不存在、窗口多大。 生图、视频、配音例外:这三种能力各家协议差异大、又都要做「提交 → 解析 → 下载」, 本库直接提供了调用实现([生图、视频与配音](/api/media));任务编排仍归应用。 ## 解决的问题 假设你有好几个桌面应用,每个都要一个「模型服务」设置页。每个页面都需要: * 一份服务商下拉(DeepSeek、智谱、通义、Kimi、OpenAI、Claude……) * 每家的 `base_url` 预填值 —— 而各家的版本段并不统一 * 每家的候选模型 id —— 而这些 id 每月都在变 * 一个「测试连接」按钮,以及失败时说得清楚的提示 于是同一套逻辑被写了好几遍。问题不在于重复本身,而在于**它们会各自漂移**: > DeepSeek 在 2026-07-24 下线了 `deepseek-chat` / `deepseek-reasoner` 两个老别名 > (V4 发布时公告的三个月过渡期到期)。当时的四个应用里只有一个及时改了预置, > 另外三个的用户打开下拉、选中默认值、点击测试 —— 报错。 > 这类问题没有任何编译期信号,只能等用户报障。 把这部分收进一个库,漏更新至少变成**全漏**而不是「有的应用对、有的错」, 而修一次就全部生效。 ## 边界:什么进、什么不进 | | 内容 | 归属 | |---|---|---| | ✅ | 预置清单(base\_url / 模型 id / 协议 / 专有字段 / 申请页) | 本 crate | | ✅ | `ai.profile` 跨应用配置交换协议 | 本 crate | | ✅ | 端点拼接、模型清单清洗 | 本 crate | | ✅ | 连通性验证与结构化错误 | 本 crate | | ✅ | 限额分层、历史裁剪、上下文超长识别 | 本 crate | | ✅ | 生图 / 视频 / 配音的调用与协议识别 | 本 crate | | ✅ | 应用定制目录的机制(增删改筛、私有预置) | 本 crate | | ❌ | **密钥存储与加密** | 留给应用 | | ❌ | 数据库、CRUD、激活态管理 | 留给应用 | | ❌ | 对话请求与流式解析、视频任务的轮询编排 | 留给应用 | | ❌ | **只属于某个应用的服务商**(合作渠道、内网网关) | 留给应用,用[定制目录](/api/catalog)加 | ### 为什么密钥存储不进来 各应用的密钥存储方式差异极大:有的落系统密钥环,有的进 SQLCipher 加密金库, 有的就放在用户数据目录的明文配置里(本地自建服务的场景)。 这些差异**不是历史包袱,而是各自合理的取舍** —— 强行统一只会让每个应用都得绕过它。 所以本 crate 一行存储代码都没有:验证时你把密钥传进来,用完即弃。 ## 能力(kind) | kind | 预置 | 调用 | 执行模型 | |---|---|---|---| | `chat` | 25 家 | 应用自己发(本库负责拼地址、验证、限额) | 同步 / 流式 | | `image` | 5 条 | ✅ OpenAI 兼容 / 通义万相 | 同步 **或** 提交+轮询 | | `video` | 8 条 | ✅ 火山方舟 / 海螺 / Vidu / 智谱 / 硅基流动 / New API | 必然提交+轮询(各家轮询协议不同) | | `tts` | 4 条 | ✅ OpenAI 兼容 / 火山语音 | 同步,返回字节流 | ## 谁在用 | 应用 | 用到的部分 | |---|---| | Sigil | 对话:预置、验证、限额、历史裁剪、`ai.profile` | | Reeve | 对话(桌面 + 移动端) | | 知识库 | 对话:预置、验证、限额、超长识别、`ai.profile` | | 一站通 | 对话 + 生图 / 视频 / 配音预置(按自身支持的协议筛选) | | StoryLoom | 四种能力全用,生图 / 视频 / 配音的调用实现就来自它 | 按 feature 开启,**不用的不编进二进制** —— 只做对话的应用不会为生图能力付出任何体积。 详见[安装与 feature](/guide/installation)。 ## 三条设计约定 这三条贯穿全库,理解它们能省下很多「为什么是这样」的疑问。 ### 1. base\_url 原样使用,不推断版本段 各家的版本段并不统一:多数是 `/v1`,智谱是 `/v4`,Gemini 是 `/v1beta/openai`(还不在末尾)。 自动补全需要引入转义机制才能表达例外,而**推断错的代价是隐性的**: 用户照着官方文档填对了地址,库悄悄给他加了一段,他只看到 404, 而且会先怀疑是自己填错了。 所以本 crate 把 `base_url` 原样当作前缀,只负责拼上端点后缀。 **唯一的例外是 Anthropic 协议**:它只有 v1,整个生态(官方 SDK、Claude Code)都约定地址填到版本段之前, 所以末段不是版本号时自动补 `/v1` —— 用户填不填都能用。这是确定的协议翻译,不是猜。 详见[端点与模型清单](/api/endpoint)。 ### 2. 默认模型选「够用档」而非「最强档」 用户点开预置是奔着"能用"来的,不是奔着"最贵"。把旗舰型号塞进默认值, 等于替他做了一个他没同意的花钱决定。最强档留在候选清单里随时可选。 ### 3. 错误必须结构化 `Err(String)` 会让调用方只能显示一行红字。而 `NotFound { suggested_url }` 才能让界面给出「一键改用这个地址」的按钮。 七个错误变体各自对应一个具体的界面动作(最后一个 `Malformed` 是兜底),详见[错误码对照](/reference/errors)。 ## 下一步 * [安装与 feature](/guide/installation) —— 把它加进你的项目 * [快速开始](/guide/quick-start) —— 十几行代码跑通验证 * [Tauri 应用接入](/guide/tauri-integration) —— 完整的前后端接入示例 --- --- url: https://ai-profile.ruoyi.plus/guide/installation.md --- # 安装与 feature ai-profile 已发布到 [crates.io](https://crates.io/crates/ai-profile),API 文档见 [docs.rs](https://docs.rs/ai-profile)(按全部 feature 构建)。 ## 加依赖 ```toml # Cargo.toml [dependencies] ai-profile = { version = "0.1", features = ["chat", "client"] } ``` 按场景选一行就够: | 你要做的 | 写法 | |---|---| | 只要预置数据与纯函数(HTTP 自己发 / 移动端) | `ai-profile = "0.1"` | | 再加「获取模型」零成本验证 | `ai-profile = { version = "0.1", features = ["client"] }` | | 再加生图 / 视频 / 配音调用 | `ai-profile = { version = "0.1", features = ["client", "image", "video", "tts"] }` | ::: tip 版本号怎么升 `0.1` 表示接受 `0.1.x` 的所有补丁版本(新模型、新服务商、修 bug),`cargo update -p ai-profile` 即可拿到; 升到 `0.2` 意味着有破坏性变更,要读[更新日志](/reference/changelog)再动手。判定规则见[版本策略](/reference/versioning)。 ::: **最低 Rust 版本:1.88**(由开启多模态时的图像解码依赖决定;只开 `chat` 的形态实际能在更老的编译器上编, 但 `rust-version` 只能写一个值,按最严的写)。 ## feature 矩阵 | feature | 默认 | 作用 | 带来的依赖 | |---|---|---|---| | `chat` | ✅ | 对话能力的预置与类型 | 无 | | `image` | ❌ | 生图预置;与 `client` 同开时加上生图调用 | 无 | | `video` | ❌ | 视频预置;与 `client` 同开时加上视频调用 | 图像解码库(大首帧压缩用) | | `tts` | ❌ | 配音预置;与 `client` 同开时加上配音调用 | 无 | | `client` | ❌ | 真实 HTTP 调用(`Verifier` / `verify`,以及开了能力后的 `media`) | `reqwest`、`tokio`、`base64`、`log` | ### 组合出什么 | 开的 feature | 得到 | |---|---| | `chat` | 预置、服务商目录、`ai.profile`、端点拼接、模型清洗、限额、历史裁剪 —— 全是纯数据与纯函数 | | `chat` + `client` | 再加「获取模型」零成本验证 | | + `image` / `video` / `tts` | 再加对应能力的预置 | | + `client` 且开了某个能力 | 再加该能力的真实调用([生图、视频与配音](/api/media)) | 默认只开 `chat`: ```toml ai-profile = "0.1" # = features = ["chat"] ``` ### 不开 client 时你得到什么 纯数据 + 纯函数:预置清单、服务商目录、`ai.profile` 解析生成、端点拼接、模型清单清洗。 **全部不需要网络,也不需要异步运行时** —— 依赖只有 `serde` / `serde_json` / `thiserror`。 这个形态能编到 Android / iOS,也适合只想用预置数据、HTTP 走自己那一套的项目。 ```toml # 只要数据层,HTTP 我自己发 ai-profile = { version = "0.1", default-features = false, features = ["chat"] } ``` ### 开 client 时多了什么 `Verifier`、`verify()` 以及围绕它们的纯函数(`diagnose` / `suggest_url` / `parse_model_ids` …)。 `reqwest` 以 `default-features = false` + `rustls-tls` 引入 —— 不拉 OpenSSL, 避免在 Windows 上触发「要装 Perl 才能编译」这类构建依赖。 ::: warning 开了 client 就把 reqwest 拉进了你的公开依赖 `Verifier::from_builder` 接收 `reqwest::ClientBuilder`,所以 **reqwest 的大版本进入了本 crate 的公开 API**。 reqwest `0.12 → 0.13` 会是本 crate 的 major 变更。 这是刻意付的代价,理由见[版本策略](/reference/versioning#_3-reqwest-的大版本在公开-api-里)。 ::: ## 多能力应用 需要多种能力时一起开。能力之间互不依赖,开哪个就有哪个的预置: ```toml # StoryLoom:对话 + 生图 + 视频 + 语音全都要 ai-profile = { version = "0.1", features = ["chat", "image", "video", "tts", "client"] } ``` ```toml # sigil:只做对话 ai-profile = { version = "0.1", features = ["chat", "client"] } ``` `Kind` 枚举的成员是按 feature 编译的 —— 没开 `video` 时 `Kind::Video` 根本不存在, 不会有「界面上出现了一个本应用不支持的选项」这种问题。 ::: danger 下游 match 必须带 `_` 分支 `Kind`、`Protocol`、`VerifyError` 等公开枚举都带 `#[non_exhaustive]`。 这让本 crate 加枚举值只算 minor 版本,代价是你的 `match` 必须写 `_ => ...` 兜底。 ::: ## 验证安装 ```rust fn main() { let all = ai_profile::presets(); println!("内置 {} 条预置", all.len()); for p in all.iter().take(3) { println!(" {} — {}", p.key, p.label); } } ``` ```text 内置 25 条预置 anthropic_official — Anthropic 官方 claude_code — Claude Code 客户端(自定义接口地址) codex — Codex 客户端(自定义接口地址) ``` ## 下一步 * [快速开始](/guide/quick-start) —— 跑通一次真实验证 * [Tauri 应用接入](/guide/tauri-integration) —— 存进 `AppState`、包成 Command * [已发布应用的接入迁移](/guide/migration) —— 用户手里已有配置时先读这篇 --- --- url: https://ai-profile.ruoyi.plus/guide/quick-start.md --- # 快速开始 本页用四步跑通一次真实验证:列出服务商 → 取一条预置 → 验证密钥 → 处理错误。 前置:已按[安装与 feature](/guide/installation) 加好依赖,并开启了 `client` feature。 ## 1. 列出服务商给用户选 用户面对的是「有哪些家」,而不是「有哪些条预置」—— 同一家可能提供对话、生图、语音多种能力, 但**共用一个密钥**。所以给界面用的是按厂商聚合后的目录: ```rust use ai_profile::{vendors, Kind}; // 传入本应用声明支持的能力 —— 只做对话的应用不会看到「只提供视频」的厂商 for v in vendors(&[Kind::Chat]) { println!("{} [{}] {}", v.label, v.group_label, if v.is_local { "本地服务" } else { "云端" }); } ``` ```text Anthropic 官方 [Anthropic / 协议档] 云端 … DeepSeek [国内] 云端 … Ollama [本地 / 自建] 本地服务 ``` (节选。实际按预置数组顺序输出,同一分组连续排列。) `is_local` 是必须区分的:云端服务要引导用户「去申请密钥」, 本地服务要引导他「先把服务跑起来」。不区分的话,用户会按云服务的思路去配,配好却连不上。 ## 2. 取预置填进表单 用户点了某家之后,用 `preset_keys` 拿到对应的预置: ```rust use ai_profile::preset_by_key; let p = preset_by_key("deepseek").expect("预置存在"); println!("地址:{}", p.base_url.unwrap_or("")); // https://api.deepseek.com/v1 println!("默认模型:{}", p.model); // deepseek-flash println!("申请密钥:{:?}", p.apply_url); for m in p.models { println!(" 候选:{}", m.value); } ``` `base_url` 为 `None` 的预置(如 Anthropic 官方、Claude Code 自定义端点档)表示 **没有预填地址** —— 调用方据此决定隐藏还是显示地址输入框。 ## 3. 验证 `Verifier` 内部持有一个可复用的 HTTP 客户端。**建一次、存起来、反复用** —— 每次现建会让连接池永远是空的,每次都从 TLS 握手重来。 ```rust use ai_profile::client::{ServiceConfig, Verifier}; #[tokio::main] async fn main() -> Result<(), Box> { // 应用启动时建一次 let verifier = Verifier::new()?; let p = ai_profile::preset_by_key("deepseek").expect("预置存在"); let cfg = ServiceConfig::new(p.protocol, "https://api.deepseek.com/v1") .with_preset("deepseek") .with_api_key("sk-你的密钥") .with_model("deepseek-flash"); match verifier.verify(cfg).await { Ok(ok) => { println!("连通 · {}ms · 端点提供 {} 个模型", ok.latency_ms, ok.models.len()); if !ok.model_in_list { println!("⚠️ 当前填的模型不在端点清单里,建议让用户确认"); } } Err(e) => println!("失败:{e}"), } Ok(()) } ``` ::: tip 验证是零成本的 `verify` 只打端点的**模型列表**接口,不产生任何生成费用。 顺带把真实模型清单拉了回来 —— 用户不必去翻文档抄模型名,直接从下拉里选。 ::: ::: warning `ServiceConfig` 只能用 builder 构造 它带 `#[non_exhaustive]`,外部 crate 不能写 `ServiceConfig { .. }` 字面量。 用 `new()` 起手,再链式调用 `with_*`。 ::: ## 4. 处理错误:给动作,不只给文字 七个错误变体各自对应一个界面动作(最后一个 `malformed` 是兜底)。**这是本库存在的重点之一** —— 只显示一行红字的话,用户不知道下一步该做什么。 ```rust use ai_profile::VerifyError; match verifier.verify(cfg).await { Ok(ok) => { /* 填充模型下拉 */ } // 404 且能推断出正确地址 → 给「一键改用」按钮 Err(VerifyError::NotFound { requested_url, suggested_url }) => { println!("打的是 {requested_url}"); if let Some(fix) = suggested_url { println!("[一键改用 {fix}]"); } } // 端点不认这个模型,但它给了真实清单 → 直接展开成下拉让用户改选 Err(VerifyError::ModelNotFound { available }) => { println!("请改选:{available:?}"); } // 少填了服务商专有字段 → 定位到那个输入框 Err(VerifyError::MissingExtraField { key }) => { println!("请先填写「{key}」"); } // 网络不通 —— 唯一「改配置也没用」的错误,给重试而不是给修改 Err(e @ VerifyError::Unreachable { .. }) => { println!("{e} · [重试]"); } Err(e) => println!("{e}"), } ``` `is_actionable()` 可以快速判断该给「去修改」还是「重试」: ```rust if e.is_actionable() { // 改配置能解决 —— 光标定位到对应输入框 } else { // 只有 Unreachable 属于这类 —— 给重试按钮 } ``` ## 一个可以少走的弯路 有些服务商要求填专有字段(如豆包 TTS 的 `appid`)。**在发请求之前**就能判出来, 不必等用户点了才报错: ```rust use ai_profile::client::check_required_fields; let extra = [("appid", "")]; let can_submit = check_required_fields(Some("some_preset"), &extra).is_ok(); // can_submit == false → 直接把「测试连接」按钮置灰 ``` 禁用态优于「点了才报错」—— 用户不会浪费一次点击,也不会怀疑是自己网络的问题。 ## 下一步 * [Tauri 应用接入](/guide/tauri-integration) —— 存进 `AppState`、包成 Command、映射错误 * [预置与服务商目录](/api/preset) —— 全部字段的含义 * [连通性验证](/api/verify) —— 代理配置、并发验证 --- --- url: https://ai-profile.ruoyi.plus/guide/cookbook.md --- # 按场景查找 不知道从哪一页看起?按你**正在做的那件事**找,每行直接给出要用的 API 和详细说明所在页。 ## 做「模型服务」设置页 | 你要做的 | 用什么 | 详见 | |---|---|---| | 服务商下拉:列出有哪些家 | `vendors(&[Kind::Chat])`(按厂商聚合,同一家多种能力共用一个密钥) | [预置与服务商目录](/api/preset) | | 选了某家后预填地址、模型、申请密钥链接 | `preset_by_key(key)` → `base_url` / `model` / `models` / `apply_url` | [预置与服务商目录](/api/preset) | | 只保留本应用调得通的几家、加自己的私有服务商 | `PresetCatalog::new().retain(…).extend(&LOCAL).build()` | [定制服务商目录](/api/catalog) | | 用户点「获取」:验证地址与密钥,顺带拉模型清单 | `Verifier::verify(ServiceConfig…)` —— 只打 `/models`,不花钱 | [连通性验证](/api/verify) | | 验证失败时给出「一键修正」而不是一行红字 | 匹配 `VerifyError` 的七个变体,404 带 `suggested_url` | [错误码对照](/reference/errors) | | 拉回的模型清单里混着向量 / 生图 / 审核模型 | `model_filter::clean_fetched_models`(排除法,滤掉的也带回) | [端点与模型清单](/api/endpoint) | | 上下文窗口、输出上限框显示多少 | `TokenLimits` 四层取值:用户 > 端点上报 > 预置 > 未知 | [限额](/api/limits) | ## 配置在应用之间互通 | 你要做的 | 用什么 | 详见 | |---|---|---| | 「粘贴导入」:用户从别的应用复制了一段配置 | `parse_profiles(text, default_model)`(单条与打包统一返回列表) | [ai.profile 协议](/api/protocol) | | 「分享 / 导出」:生成一段别的应用能粘的配置 | `to_profile(…)` | [ai.profile 协议](/api/protocol) | | 用户粘的不是 ai.profile,给出准确提示 | 匹配 `ParseError`(`not_ai_profile` / `missing_data` / `empty_bundle` …) | [ai.profile 协议](/api/protocol) | ## 发对话请求之前 | 你要做的 | 用什么 | 详见 | |---|---|---| | 拼出对话端点地址 | `endpoint::join_chat_endpoint(base, "chat/completions" \| "messages")` | [端点与模型清单](/api/endpoint) | | Anthropic 地址用户只填了主机名 | 不用管 —— Anthropic 协议自动补 `/v1`,填不填都能用 | [端点与模型清单](/api/endpoint#anthropic-协议自动补-v1) | | 历史太长,按窗口裁掉旧消息 | `history::history_budget` + `history::trim_history` | [历史裁剪与超长重试](/api/history) | | 服务端报「上下文超长」,自动裁一半重试 | `history::is_context_overflow` + `history::retry_budget` | [历史裁剪与超长重试](/api/history) | | `max_tokens` 别超过模型上限 | `TokenLimits::max_output` 只用来收窄 | [限额](/api/limits) | ::: tip 对话请求本身不在本库 请求格式、流式解析、鉴权头都是应用自己的 —— 本库只负责在那之前把地址、模型、窗口算对。 ::: ## 生图、视频、配音 | 你要做的 | 用什么 | 详见 | |---|---|---| | 按配置出一张图(自动识别协议) | `media::image::AnyImageProvider::from_config_with(cfg, &http).generate(…)` | [生图、视频与配音](/api/media) | | 提交视频任务、轮询结果 | `media::video::AnyVideoProvider::from_config_with(…)` 的 `submit` / `poll` | [生图、视频与配音](/api/media) | | 这家视频支不支持首尾帧 | `media::video::supports_last_frame(endpoint, extra)` | [生图、视频与配音](/api/media) | | 合成一段配音 | `media::tts::synthesize_with(…)` | [生图、视频与配音](/api/media) | | 调用要走用户配的代理 | `media::MediaHttp::from_fn(\|\| builder)` —— 超时仍由本库施加 | [生图、视频与配音](/api/media) | ## 接入与维护 | 你要做的 | 看哪篇 | |---|---| | 第一次接入一个 Tauri 应用 | [Tauri 应用接入](/guide/tauri-integration) → [前端对接](/guide/frontend) | | 应用**已经发布过**,用户手里存着旧配置 | [已发布应用的接入迁移](/guide/migration)(先读这篇再换库) | | 想加一家服务商 / 一个新模型 | [加一家服务商](/reference/add-provider) —— 改本库,下游只升版本 | | 升级本库时判断要不要改代码 | [更新日志](/reference/changelog) + [版本策略](/reference/versioning) | --- --- url: https://ai-profile.ruoyi.plus/guide/ai-assisted.md --- # 用 AI 接入 用 Claude Code、Codex、Cursor 等 AI 编程助手把本库接进你的项目。 本库 2026-09 才发布,**AI 的训练数据里没有它** —— 不给它资料,它只能凭印象猜 API, 还会踩几个我们在五个应用接入时踩过的坑。下面三样东西就是为此准备的。 | 给 AI 的材料 | 地址 | 用途 | |---|---|---| | 全文文档(纯文本) | [`/llms-full.txt`](/llms-full.txt) | 整站文档合成一个文件,AI 一次读完 | | 文档索引 | [`/llms.txt`](/llms.txt) | 页面清单与摘要,AI 按需取页 | | 接入技能 | /ai/ai-profile-integration.md | 放进项目,AI 以后每次改相关代码都会照它做 | | 可运行示例 | [GitHub · examples](https://github.com/bkywksj/ai-profile/tree/master/crates/ai-profile/examples) | 能编译的完整代码,AI 照着改最可靠 | ## 方式一:贴一段提示词(一次性接入) 把下面这段贴给 AI 助手,按你的项目改一下方括号里的内容: ```text 帮我把 ai-profile(Rust crate,crates.io 上的 ai-profile 0.1)接进这个项目, 用来做 [模型服务设置页 / 粘贴导入 / 对话前的历史裁剪 / 生图视频配音]。 开始前先读这两份资料,不要凭印象写,这个库很新: 1. 接入规则:https://ai-profile.ruoyi.plus/ai/ai-profile-integration.md 2. 完整文档:https://ai-profile.ruoyi.plus/llms-full.txt 示例代码在 https://github.com/bkywksj/ai-profile/tree/master/crates/ai-profile/examples 要求: - 先找出项目里已有的服务商清单、模型列表、地址拼接、ai.profile 解析,列给我看,接入后删掉它们 - [本项目已经发布过 / 还没发布]。如果发布过,先按文档里的「已发布应用的接入迁移」写存量地址修正和对照测试 - 改完跑全量测试 ``` ::: tip 为什么强调「先读资料」 AI 最常见的错误都来自凭印象写:给地址手动补 `/v1`、`match` 枚举不写 `_` 兜底、每次调用都新建客户端、 在项目里又抄一份服务商清单。接入规则里把这些逐条写明了。 ::: ## 方式二:把接入技能放进项目(长期维护) 一次性提示词只管这一次。把接入技能放进项目,AI 以后每次改模型服务相关的代码都会自动遵守同样的规则。 下载 ai-profile-integration.md(浏览器里打开后另存为),按你用的工具放: | 工具 | 放到哪 | |---|---| | Claude Code | `.claude/skills/ai-profile-integration/SKILL.md`(文件自带触发条件,会按需自动启用) | | Codex | `.codex/skills/ai-profile-integration/SKILL.md`,或把正文贴进项目根的 `AGENTS.md` | | Cursor | 正文存为 `.cursor/rules/ai-profile.mdc` | | 其他工具 | 贴进该工具的项目级说明文件 | 接入完成后,建议在技能末尾补上**本项目自己的接入点**(设置页组件在哪、验证器存在哪个全局状态里、 密钥怎么存),以后 AI 找代码更快。 ## 方式三:只给文档地址 AI 工具支持读取网址时,直接给它 [`https://ai-profile.ruoyi.plus/llms-full.txt`](/llms-full.txt) 即可。 这是整站文档的纯文本版本,随文档自动更新。 ## 接入完怎么检查 不管用哪种方式,AI 交活后对照这几条看一眼: * \[ ] 项目里**没有**残留的服务商清单、模型列表、地址拼接函数(搜一下 `api.deepseek.com`、`/chat/completions` 这类字符串) * \[ ] `match VerifyError` / `match ParseError` 都带了 `_` 分支 * \[ ] `Verifier` 只建了一次,存在全局状态里 * \[ ] 「获取模型」失败时界面给的是动作(一键改用地址、高亮密钥框),不只是一行红字 * \[ ] 项目已经发布过的:有存量地址修正和对照测试,并用真实库副本跑过迁移 * \[ ] 全量测试通过 ## 相关章节 * [快速开始](/guide/quick-start) —— 人读的入门 * [按场景查找](/guide/cookbook) —— 按要做的事查 API * [已发布应用的接入迁移](/guide/migration) —— 用户手里已有配置时必读 * [其他语言实现](/reference/spec#用-ai-实现) —— 不是 Rust 项目?让 AI 照一致性用例写一份 --- --- url: https://ai-profile.ruoyi.plus/guide/tauri-integration.md --- # Tauri 应用接入 本页是一份完整的接入示例:从加依赖到前端拿到结构化错误。 示例基于 Tauri 2.x + React,但除了 Command 那一层,其余对任何 Rust 应用都适用。 ## 1. 加依赖 ```toml # src-tauri/Cargo.toml [dependencies] ai-profile = { version = "0.1", features = ["chat", "client"] } ``` 如果你的应用还要生图 / 语音,在 `features` 里一并开启 —— 见 [feature 矩阵](/guide/installation#feature-矩阵)。 ## 2. 把 Verifier 存进 AppState `Verifier` 内部持有 `reqwest::Client` 的连接池。**它是需要长期持有的资源,不是每次调用现建的临时对象**。 ```rust // src-tauri/src/state.rs use ai_profile::client::Verifier; pub struct AppState { pub db: Database, /// 模型服务验证器 —— 持有连接池,全应用共用一个。 /// /// 🔴 不要在 Command 里现建:reqwest::Client 的连接池是它的全部价值, /// 每次新建等于每次都从 TCP + TLS 握手重来,跨境端点尤其明显。 pub verifier: Verifier, } ``` ### 带上应用自己的代理设置 多数桌面应用有自己的代理配置。用 `from_builder` 把它注入进来: ```rust // src-tauri/src/lib.rs —— setup 阶段 use ai_profile::client::Verifier; // 用本 crate 重导出的 reqwest 建 builder,版本必然匹配 let builder = crate::proxy::apply_proxy(ai_profile::reqwest::Client::builder()); let verifier = Verifier::from_builder(builder) .map_err(|e| AppError::Custom(format!("模型服务验证器初始化失败: {e}")))?; app.manage(AppState { db, verifier }); ``` ::: tip 用 `ai_profile::reqwest`,不要用自己依赖树里的 reqwest 两边版本一旦不同,报错会是 `expected ClientBuilder, found ClientBuilder` 这种看不懂的形式。 本 crate 重导出 `reqwest` 就是为了消掉这个坑。 ::: ::: warning 超时与禁重定向你覆盖不掉 `from_builder` 会在**你的配置之后**再施加超时和禁重定向。 禁重定向不是可选项:跨 host 跳转时 reqwest 只剥 `Authorization` 等标准头、 **不剥自定义头** —— Anthropic 的 `x-api-key` 会被原样发往跳转目标。 ::: ## 3. Service 层 按三层架构,Command 只做 IPC 包装,逻辑放 Service: ```rust // src-tauri/src/services/model_service.rs use ai_profile::client::{ServiceConfig, Verifier, check_required_fields}; use ai_profile::{Kind, ProviderPreset, VerifyError, Vendor, vendors, preset_by_key}; /// 本应用声明支持的能力 —— 只做对话。 const SUPPORTED: &[Kind] = &[Kind::Chat]; pub struct ModelService; impl ModelService { /// 服务商目录,给设置页的卡片列表用 pub fn vendor_catalog() -> Vec { vendors(SUPPORTED) } /// 某一条预置的完整信息,给表单预填用 pub fn preset(key: &str) -> Option<&'static ProviderPreset> { preset_by_key(key) } /// 验证一条配置 pub async fn verify( verifier: &Verifier, preset_key: Option<&str>, base_url: &str, api_key: &str, model: &str, extra: &[(&str, &str)], ) -> Result { // 缺必填专有字段的话不必发请求 —— 但前端本就该把按钮置灰, // 这里是第二道闸门(前端可能被绕过,也可能是别的调用方) check_required_fields(preset_key, extra)?; let protocol = preset_key .and_then(preset_by_key) .map(|p| p.protocol) // 没有预置就按 base_url 反推 .unwrap_or_else(|| { let key = ai_profile::preset::infer_preset_key( ai_profile::Protocol::OpenAiCompatible, Some(base_url), ); preset_by_key(key).map(|p| p.protocol) .unwrap_or(ai_profile::Protocol::OpenAiCompatible) }); let mut cfg = ServiceConfig::new(protocol, base_url) .with_api_key(api_key) .with_model(model) .with_extra(extra); if let Some(k) = preset_key { cfg = cfg.with_preset(k); } verifier.verify(cfg).await } } ``` ## 4. 错误映射:别把结构拍扁 这是最容易做错的一步。**不要**这样写: ```rust // ❌ 结构没了,前端只能显示一行红字 .map_err(|e| CommandError { code: "VERIFY_FAILED".into(), message: e.to_string() }) ``` 这样做等于把本库最有价值的部分丢掉了:`suggested_url` 没了,「一键改用」按钮就做不出来。 有两种做法能保住结构,选哪个取决于你的应用有没有统一的 `CommandError` 约定。 ### 做法 A:让验证结果成为返回值(推荐) 「端点连不上」是**验证的结果**,不是 Command 执行失败 —— Command 成功地完成了 验证工作。真正的 `Err` 留给读库失败、解密失败这类调用方无能为力的情况。 这样做还有个实际好处:应用里那套 `Result` 的约定一条不破。 ```rust /// 成功与失败都是正常返回值 #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct VerifyOutcome { pub ok: bool, pub result: Option, /// 带 `code` 判别字段,前端据此给动作 pub error: Option, /// 失败是否可能通过「改配置」解决;false = 只能重试 pub actionable: bool, } #[tauri::command] pub async fn verify_model_service( state: tauri::State<'_, AppState>, preset_key: Option, base_url: String, api_key: String, model: String, ) -> Result { let cfg = /* … 见上一节 … */; Ok(match state.verifier.verify(cfg).await { Ok(ok) => VerifyOutcome { ok: true, result: Some(ok), error: None, actionable: true }, Err(e) => VerifyOutcome { ok: false, result: None, actionable: e.is_actionable(), error: Some(e), }, }) } ``` sigil 就是这么接的。 ### 做法 B:直接返回 VerifyError 应用没有统一错误类型时更简单 —— `VerifyError` 自身就是结构化的: ```rust #[tauri::command] pub async fn verify_model_service(/* … */) -> Result { state.verifier.verify(cfg).await } ``` 代价是这个 Command 的错误形状与应用里其它 Command 不一致 —— 前端那套 `getErrorMessage` / `getErrorCode` 辅助函数对它不适用。 ```rust #[tauri::command] pub fn list_model_vendors() -> Vec { ModelService::vendor_catalog() } ``` 别忘了注册: ```rust // src-tauri/src/lib.rs .invoke_handler(tauri::generate_handler![ // …既有的 commands::model_service::verify_model_service, commands::model_service::list_model_vendors, ]) ``` ::: danger 密钥不要写进日志 `VerifyError` 的所有 `detail` 字段只放端点返回的文本摘要(截断 300 字符), **密钥绝不会进错误信息** —— 所以你可以放心 `log::warn!("{e}")`。 但别自己把 `api_key` 拼进日志行。 ::: ## 5. 批量验证 设置页常有「全部测试」按钮。`verify` 只借 `&self`,直接并发即可: ```rust use futures_util::future::join_all; let results = join_all(configs.iter().map(|c| { let v = &state.verifier; async move { (c.id, ModelService::verify(v, /* … */).await) } })) .await; ``` 共用一个 `Verifier` 意味着共用连接池 —— 同一家的多条配置只握手一次。 ## 6. 迁移存量配置 用户升级前已经存了一堆配置。`infer_preset_key` 能从 `base_url` 反推出对应的预置模板, 让老配置回到「认识的服务商」而不是掉进「自定义端点」: ```rust use ai_profile::{preset::infer_preset_key, Protocol}; let key = infer_preset_key(Protocol::OpenAiCompatible, Some(&row.base_url)); // "deepseek" —— 即使用户存的是不带版本段的 https://api.deepseek.com ``` 它按 **host 而非完整字符串**匹配,所以带不带 `/v1`、带不带结尾斜杠都认得回来。 ::: warning `preset.key` 是存量配置的锚 改预置的 `key` 会让所有老配置掉进「自定义端点」—— 用户看到的是 "我配好的服务商突然不认识了"。这在本库里被定为 **major 变更**, 你的应用侧同理:别把 `key` 当可以随手改的展示文本。 ::: ## 下一步 * [前端对接](/guide/frontend) —— 字段命名的坑、按 `code` 分支 * [连通性验证](/api/verify) —— `Verifier` 的完整 API --- --- url: https://ai-profile.ruoyi.plus/guide/frontend.md --- # 前端对接 本页讲 Rust 侧的类型序列化成 JSON 之后,前端拿到的确切形状,以及怎么按错误码分支。 ## 🔴 先说一个坑:两套命名风格并存 这不是疏忽,但确实需要记住: | 类型 | 字段风格 | 例子 | |---|---|---| | `ProviderPreset`、`Vendor`、`ModelOption`、`ExtraField` | **camelCase** | `vendorId`、`baseUrl`、`isLocal`、`applyUrl` | | `VerifyError`、`ParseError` | **snake\_case** | `requested_url`、`suggested_url`、`proxy_hint` | 原因是两类数据的定位不同:预置是**喂给界面渲染的数据**,跟着前端习惯走 camelCase; 错误是**带判别标签的协议**,`code` 的取值本身就是 snake\_case(`not_found`、`auth_failed`), 字段跟着保持一致更好认。 记混的表现是 `err.suggestedUrl` 永远是 `undefined`,而且不会报错 —— 所以这里明确列出来。 ## 预置数据的真实形状 `ProviderPreset` 序列化后: ```json { "key": "deepseek", "vendorId": "deepseek", "kind": "chat", "groupKey": "providerGroup.china", "groupLabel": "国内", "labelKey": "providerTemplate.deepseek.label", "label": "DeepSeek", "hintKey": "providerTemplate.deepseek.hint", "hint": "deepseek-flash / v4-pro;国内直连,不需要代理", "baseUrl": "https://api.deepseek.com/v1", "model": "deepseek-flash", "models": [ { "value": "deepseek-flash", "label": "deepseek-flash", "contextWindow": 1000000, "maxOutput": 384000 }, { "value": "deepseek-v4-pro", "label": "deepseek-v4-pro", "contextWindow": 1000000, "maxOutput": 384000 } ], "protocol": "openai_compatible", "matchHosts": ["api.deepseek.com"], "extraFields": [], "defaultExtra": [], "applyUrl": "https://platform.deepseek.com/api_keys", "isLocal": false, "verifiedAt": "2026-09-17" } ``` `protocol` 取值为 `"openai_compatible"` 或 `"anthropic"`,与 Rust 侧 `Protocol::as_str()` 同一套拼写 —— 前端可以直接拿它和后端存的值比较,不需要转换层。 `Vendor`(服务商目录卡片): ```json { "id": "deepseek", "label": "DeepSeek", "groupKey": "providerGroup.china", "groupLabel": "国内", "kinds": ["chat"], "presetKeys": ["deepseek"], "host": "api.deepseek.com", "applyUrl": "https://platform.deepseek.com/api_keys", "isLocal": false } ``` ### TypeScript 类型 ```typescript // src/types/model-service.ts export type Kind = "chat" | "image" | "video" | "tts"; export type Protocol = "anthropic" | "openai_compatible"; export interface ModelOption { value: string; label: string; /** 预置的静态兜底限额;null = 文档没写,让用户手填 */ contextWindow: number | null; maxOutput: number | null; } export interface ExtraField { key: string; labelKey: string; label: string; placeholder: string | null; required: boolean; } export interface ProviderPreset { key: string; vendorId: string; kind: Kind; groupKey: string; groupLabel: string; labelKey: string; label: string; hintKey: string | null; hint: string | null; /** null = 没有预填地址,应显示地址输入框让用户自己填 */ baseUrl: string | null; model: string; models: ModelOption[]; protocol: Protocol; matchHosts: string[]; extraFields: ExtraField[]; /** 预置定死的 extra 键值,新建配置时写进 extra(如视频 New API 的 video_api=newapi) */ defaultExtra: [string, string][]; applyUrl: string | null; isLocal: boolean; /** null = 未实际核实过,可给一个淡色提示 */ verifiedAt: string | null; } export interface Vendor { id: string; label: string; groupKey: string; groupLabel: string; kinds: Kind[]; presetKeys: string[]; host: string | null; applyUrl: string | null; isLocal: boolean; } /** 限额数字的来源 —— 决定界面是直接用还是让用户能改 */ export type LimitSource = "user" | "endpoint" | "preset"; export interface TokenLimits { /** 上下文窗口(输入 + 输出总量);null = 未知,让用户手填 */ contextWindow: number | null; /** 单次输出上限;null = 未知 */ maxOutput: number | null; /** 🔴 user = 用户手填;endpoint = 端点上报的事实;preset = 本库的估计值,应允许用户修改 */ source: LimitSource; } export interface VerifyOk { latencyMs: number; models: string[]; /** 滤掉的非对话模型条数 —— 用于「已滤掉 N 个」提示 */ dropped: number; /** 被滤掉的模型 id(端点顺序)—— 一条配置同时挂生图 / 配音模型时接在 models 后面 */ droppedModels: string[]; modelInList: boolean; /** 当前填的那个模型的限额;null = 端点没报,回落到预置静态值 */ limits: TokenLimits | null; /** 端点报了限额的全部模型,[id, 限额]。用于「换个模型立刻显示新窗口」 */ modelLimits: [string, TokenLimits][]; } ``` ## 错误的真实形状 `VerifyError` 带 `code` 判别字段。七个变体(最后一个是兜底): ```json { "code": "auth_failed", "detail": "Invalid API key" } { "code": "not_found", "requested_url": "https://api.deepseek.com/models", "suggested_url": "https://api.deepseek.com/v1" } { "code": "unreachable", "proxy_hint": true } { "code": "model_not_found", "available": ["deepseek-flash", "deepseek-v4-pro"] } { "code": "protocol_mismatch", "expect": "anthropic" } { "code": "missing_extra_field", "key": "appid" } { "code": "malformed", "detail": "端点返回了无法解析的内容" } ``` `suggested_url` 在推断不出时是 `null`,不是缺字段 —— 判断用 `!= null` 而不是 `in`。 ### TypeScript 判别联合 ```typescript export type VerifyError = | { code: "auth_failed"; detail: string } | { code: "not_found"; requested_url: string; suggested_url: string | null } | { code: "unreachable"; proxy_hint: boolean } | { code: "model_not_found"; available: string[] } | { code: "protocol_mismatch"; expect: Protocol } | { code: "missing_extra_field"; key: string } | { code: "malformed"; detail: string }; ``` ## 按 code 给动作 结构化错误的意义就在这一步 —— 每个分支给的是**按钮**,不是文案: ```tsx import { message, Button } from "antd"; import { invoke } from "@tauri-apps/api/core"; async function handleVerify() { setTesting(true); try { const ok = await invoke("verify_model_service", { presetKey: form.presetKey, baseUrl: form.baseUrl, apiKey: form.apiKey, model: form.model, }); setModelOptions(ok.models); // 顺带把真实清单填进下拉 message.success(`连通 · ${ok.latencyMs}ms`); if (!ok.modelInList) { message.warning("当前模型不在端点清单里,请确认模型名"); } } catch (e) { handleVerifyError(e as VerifyError); } finally { setTesting(false); } } function handleVerifyError(err: VerifyError) { switch (err.code) { case "not_found": // 有推断地址 → 给「一键改用」,没有 → 只说明打了哪个地址 if (err.suggested_url) { const fix = err.suggested_url; message.error({ content: ( 端点不存在 ), }); } else { message.error(`端点不存在:${err.requested_url}`); } break; case "model_not_found": // 端点给了真实清单 → 直接展开下拉,不让用户自己猜 setModelOptions(err.available); message.warning("端点不认识该模型,已为你载入可用清单"); break; case "missing_extra_field": // 定位到那个输入框,比弹一句提示有用 form.scrollToField(err.key); form.setFields([{ name: err.key, errors: ["该服务商要求填写此项"] }]); break; case "unreachable": // 唯一「改配置也没用」的错误 —— 给重试,不是给修改 message.error(err.proxy_hint ? "无法连接,国内访问该站点通常需要代理" : "无法连接到端点"); break; case "auth_failed": form.setFields([{ name: "apiKey", errors: [err.detail] }]); break; case "protocol_mismatch": message.error(`该密钥要求 ${err.expect} 协议,请切换对应的服务商模板`); break; default: // 🔴 必须有:VerifyError 会增加变体(minor 版本),别让新变体把界面打空 message.error("验证失败,请检查配置"); } } ``` ::: danger default 分支不能省 `VerifyError` 带 `#[non_exhaustive]` —— 本库新增错误变体只算 minor 版本。 没有 `default` 的话,新变体会让你的界面什么都不显示,而这是最难排查的一类故障。 ::: ## 表单顺序的一个建议 按用户**实际填写的顺序**排字段,而不是按数据结构的顺序: ``` 服务商 → 基础连接(接口地址、密钥) → 模型 ``` 模型下拉依赖「先能连上端点」才能拉到真实清单。把模型放在地址前面, 用户会先面对一个空下拉,然后才明白要先填地址 —— 顺序反了,界面就在制造困惑。 ## 下一步 * [错误码对照](/reference/errors) —— 每个变体对应的界面动作完整清单 * [预置与服务商目录](/api/preset) —— 字段含义 --- --- url: https://ai-profile.ruoyi.plus/guide/migration.md --- # 已发布应用的接入迁移 新项目直接按[快速开始](/guide/quick-start)接入即可。本页写给**已经发布过、用户手里存着配置**的应用 —— 换成本库之前,有一件事必须先做:**把存量地址修正成本库的语义**。 ## 为什么要修 本库的约定是 base\_url **原样使用、不推断版本段**。而几乎每个应用原先的拼接逻辑都会「帮用户补一段」: | 应用 | 原拼接规则 | 换成本库后会坏的存量地址 | |---|---|---| | Reeve | 末段不是版本号就补 `/v1`,末尾 `#` 可禁止 | `https://api.deepseek.com` | | 知识库 | 同上,但把 `v1.5` 也算版本段 | 同上 | | 一站通 | 只有主机名时补 `/v1`,带路径就直接拼 | `https://api.anthropic.com` | | StoryLoom | OpenAI 兼容从不补;**Anthropic** 不以 `/v1` 结尾就补 | —— Anthropic 一侧本库现在也会补,见下方提示 | 这些地址在老版本里一直能用,因为库在背后补了 `/v1`。直接换成本库,它们会全部 404,而用户看不出原因。 ::: tip 四家的规则各不相同 上表就是证据:**不能共用一份修正逻辑**。每个应用冻结自己的旧规则,这正是修正逻辑留在应用、不进本库的理由。 ::: ::: info Anthropic 协议的地址不再需要迁移 本库对 Anthropic 协议会自动补 `/v1`(见[端点拼接](/api/endpoint#anthropic-协议自动补-v1)), 所以只有主机名的 Anthropic 地址换成本库后照样能用。迁移只需要处理 **OpenAI 兼容**一侧。 已经写好的 Anthropic 迁移(补过 `/v1` 的)也没问题:带 `/v1` 的地址原样使用。 ::: ## 做法:冻结旧规则 + 对照测试 ### 1. 在测试里冻结一份旧规则 把原来的拼接函数**原样**复制进测试模块,改名 `legacy_*`,注释写明「不要改」: ```rust #[cfg(test)] mod tests { /// 🔴 旧拼接规则的冻结副本,**不要改** —— 它是对照测试的基准 fn legacy_join(base: &str, path: &str) -> String { let b = base.trim().trim_end_matches('/'); let after_scheme = b.splitn(2, "://").nth(1).unwrap_or(b); if after_scheme.contains('/') { format!("{b}/{path}") } else { format!("{b}/v1/{path}") } } } ``` ### 2. 写修正函数 输入旧地址,输出「在本库语义下与旧规则等价」的地址;不用改时返回 `None`: ```rust pub fn fix_legacy_base_url(raw: &str) -> Option { let b = raw.trim().trim_end_matches('/'); if b.is_empty() { return None; } let after_scheme = b.split_once("://").map_or(b, |(_, rest)| rest); if after_scheme.contains('/') { None } else { Some(format!("{b}/v1")) } } ``` ### 3. 对照测试是判据 对一批真实可能出现的地址、每一条会用到的端点路径,断言**旧规则(原值) == 本库(修正值)**: ```rust #[test] fn fixed_url_hits_same_endpoint_as_legacy_rule() { for raw in RAWS { let fixed = fix_legacy_base_url(raw).unwrap_or_else(|| raw.to_string()); for path in ["chat/completions", "messages", "models", "embeddings", "images/generations", "audio/speech"] { assert_eq!(crate_join(&fixed, path), legacy_join(raw, path), "raw={raw} path={path}"); } } } ``` 这条测试证明的是「升级前后请求的是**同一个地址**」—— 不是「修正后的地址看起来对」。 ### 4. 修正必须幂等 修过的地址再修一次必须不变。迁移可能因为崩溃重跑,整库恢复后也可能再跑一遍: ```rust #[test] fn fix_is_idempotent() { for raw in RAWS { if let Some(fixed) = fix_legacy_base_url(raw) { assert_eq!(fix_legacy_base_url(&fixed), None); } } } ``` 带 `#` 这类「禁止推断」标记的地址原样保留 —— 去掉 `#` 再修一次会被补成 `…/v1`。 ## 只对旧来源修正 🔴 这是最容易出错的一条:**新数据不能再修**。 用户在新版本里刻意只填主机名(某个服务商就是这么要求的),被你悄悄补上 `/v1`,同样是 404。 | 入口 | 修不修 | |---|---| | 数据库升级迁移(一次) | ✅ | | 整库恢复(备份文件、同步盘) | ✅ 按备份的 schema 版本判断是不是旧数据 | | 应用自己的旧版导出格式 | ✅ 按信封里的版本 / 标记判断 | | 新建、编辑 | ❌ | | `ai.profile` 导入 | ❌ 按本库语义原样用。Anthropic 协议只填主机名的(Claude Code 习惯)由拼接时自动补 `/v1` 兜住 | 迁移放进事务:要么全改、要么全不改。 ## 协议判断也要冻结 如果旧版按地址猜协议(例如「端点含 anthropic 就走 Anthropic」),迁移里判断「这条是不是 Anthropic 配置」 **也要用旧口径** —— 否则一条旧版当成 Anthropic 用了半年的配置,会因为新口径不同而漏修。 同理,旧版按域名识别的专有协议(例如某个 New API 视频中转站),换成本库后要在迁移里 把标记补进 `extra`(`video_api = newapi`),见[定制服务商目录](/api/catalog#default-extra-预置定死的配置)。 ## 上线前的检查 * 拿一份**真实用户库的拷贝**跑迁移(只读原库),看改了哪几条、有没有误伤 * 对照测试覆盖到应用实际会拼的**每一条**路径(对话、模型列表、向量、生图、配音……) * 迁移跑两遍,结果不变 ## 相关章节 * [端点与模型清单](/api/endpoint) —— 本库的拼接规则 * [Tauri 应用接入](/guide/tauri-integration) —— 接入的其余步骤 --- --- url: https://ai-profile.ruoyi.plus/api/preset.md --- # 预置与服务商目录 预置(`ProviderPreset`)是一条「某家服务商的某种能力」的配置模板。 服务商目录(`Vendor`)是把同一家的多条预置聚合成一张卡片。 界面上**先选服务商、再选能力**,所以目录是给列表用的,预置是给表单用的。 ## 取预置 ```rust use ai_profile::{presets, presets_for, preset_by_key, Kind}; let all = presets(); // &'static [ProviderPreset],全部 let chat = presets_for(Kind::Chat); // 迭代器,按 kind 过滤 let p = preset_by_key("deepseek"); // Option<&'static ProviderPreset> ``` 全部是 `&'static` —— 编译进二进制的静态数据,取用零分配,可以随意跨线程共享。 ::: tip 数组顺序即呈现顺序 预置数组的顺序就是下拉/列表该呈现的顺序,**同一分组必须连续**(有守卫测试盯着)。 调用方直接按顺序渲染即可,不需要自己排序。 排序依据是「用户找到它的概率」:Anthropic 协议档 → 国内 → 国际 → 本地自建。 ::: ## ProviderPreset 字段 | 字段 | 类型 | 说明 | |---|---|---| | `key` | `&'static str` | 模板 key,也是**存量配置回填的锚**。🔴 改名 = major | | `vendor_id` | `&'static str` | 跨 kind 聚合的依据。同一家的 chat/image/tts 共用它 | | `kind` | `Kind` | 这条属于哪种能力 | | `group_key` / `group_label` | `&'static str` | 分组(i18n key / 纯文本) | | `label_key` / `label` | `&'static str` | 服务商名(i18n key / 纯文本) | | `hint_key` / `hint` | `Option<&'static str>` | 下拉项副文本 | | `base_url` | `Option<&'static str>` | 预填地址。**含版本段、不含端点后缀**;`None` = 无预填 | | `model` | `&'static str` | 默认模型;空串 = 让用户自己填 | | `models` | `&'static [ModelOption]` | 建议清单 | | `protocol` | `Protocol` | 走 `/v1/messages` 还是 `/v1/chat/completions` | | `match_hosts` | `&'static [&'static str]` | 反推用的 host 片段;空 = 靠 protocol 反推 | | `extra_fields` | `&'static [ExtraField]` | 服务商专有字段 | | `apply_url` | `Option<&'static str>` | 密钥申请页;`None` = 本地服务不需申请 | | `is_local` | `bool` | 需用户先把服务跑起来(Ollama / LM Studio / vLLM) | | `verified_at` | `Option<&'static str>` | 最后一次**实际调通**的日期;`None` = 未核实 | ### 几个字段的用法要点 **`base_url` 为 `None`** 表示没有预填地址。两种情况:官方端点固定(Anthropic 官方), 或这是个「自定义端点」档(Claude Code / Codex 客户端档)。 调用方据此**隐藏或显示地址输入框**。 **`model` 为空串**出现在本地推理服务上 —— Ollama 上装了什么模型因人而异, 预置里写死任何一个都会是错的。 **`is_local` 必须区分对待**: ```rust if p.is_local { // 「先在本机启动 Ollama,再回来测试连接」 } else if let Some(url) = p.apply_url { // 「去 {url} 申请密钥」 } ``` 不区分的话,用户会按云服务的思路去配本地服务,配好却连不上,而且不知道为什么。 **`verified_at` 为 `None` 不代表不可用**,只代表这条数据是照官方文档抄的、 没有人拿真实密钥调通过。界面可以给一个淡色的「未核实」提示。 留白则会让用户以为"没这回事",反而不如标出来。 ## 服务商目录 ```rust use ai_profile::{vendors, Kind}; // 传入本应用支持的能力 let list = vendors(&[Kind::Chat]); ``` `vendors` 只返回与 `allow_kinds` **有交集**的厂商 —— 只做对话的应用不会看到 一家只提供视频的服务商。返回顺序沿用预置数组顺序。 | 字段 | 说明 | |---|---| | `id` | 对应 `vendor_id` | | `label` | 展示名,取该厂商第一条预置的 `label` | | `group_key` / `group_label` | 分组 | | `kinds` | 这家覆盖哪几种能力 | | `preset_keys` | 该厂商下所有预置的 key,点卡片后据此打开对应表单 | | `host` | base\_url 的 host;`None` = 自定义端点档 | | `apply_url` | 密钥申请页 | | `is_local` | 本地服务 | ### 「同密钥多能力」的由来 `vendor_id` 相同的预置,其 `base_url` 的 **host 必须一致** —— 有守卫测试 `vendor_ids_consistent` 盯着。这条约束支撑了目录的核心承诺: > 同一家的多种能力**共用一个密钥**,配一次就能全部启用。 典型例子是火山方舟:它的对话、生图、视频走的是同一个 `https://ark.cn-beijing.volces.com/api/v3`。用户填一次密钥,三种能力全通。 `vendors_all()` 返回全部厂商,不按 kind 过滤 —— 用于生成文档这类场景。 ## 从存量配置反推 老用户的数据库里只有 `base_url` 字符串。`infer_preset_key` 把它认回对应的模板: ```rust use ai_profile::{preset::infer_preset_key, Protocol}; let key = infer_preset_key(Protocol::OpenAiCompatible, Some("https://api.deepseek.com")); assert_eq!(key, "deepseek"); // 即使存的地址不带 /v1 ``` 它按 **host 而非完整字符串**匹配(`match_hosts`),所以带不带版本段、 带不带结尾斜杠都认得回来。认不出时返回 `CUSTOM_PRESET_KEY`(`"openai_compatible_custom"`)。 `match_hosts` 为空的预置(自定义端点档)靠 `protocol` 反推 —— 协议是 Anthropic 但 host 不是 `api.anthropic.com` 的,多半是中转站,归到 Claude Code 那一档。 ## 专有字段 有些服务商要求额外配置项(如豆包 TTS 的 `appid` / `cluster`)。 这类字段**按服务商而非按能力变化** —— 同是 TTS,硅基流动就不需要 `appid`。 所以它们放在数据里而不是写死在界面上: ```rust for f in p.extra_fields { // f.key / f.label / f.placeholder / f.required // 渲染成一个输入框,收集后存进你自己的 extra JSON } ``` 加一家新服务商时只改本 crate 的一个数组字面量,**所有下游应用同时生效**, 不需要任何一方改前端。 ## 相关 * [服务商清单](/reference/providers) —— 当前全部预置的实际数据 * [定制服务商目录](/api/catalog) —— 应用自己增删改筛、加私有条目 * [加一家服务商](/reference/add-provider) —— 提 PR 的完整流程 * [前端对接](/guide/frontend) —— 这些类型序列化后的确切 JSON --- --- url: https://ai-profile.ruoyi.plus/api/catalog.md --- # 定制服务商目录 本库内置的预置是「所有应用都该看到」的公共部分。但总有只属于某个应用的条目 —— 自家的合作渠道、公司内网网关;或者某个应用的调用实现只支持部分协议,要藏掉一些家。 把这些塞进本库,**所有**应用的下拉里都会出现它;让应用整份抄走预置,又回到了「各存一份、各自漂移」。 所以本库只放公共数据,应用用 `PresetCatalog` 在它上面叠自己的改动。 ## 增、删、改、筛 ```rust use ai_profile::preset::{ModelOption, PresetCatalog, ProviderPreset, GROUP_CHINA}; use ai_profile::Kind; // 只属于本应用的条目:写成静态表 static LOCAL: &[ProviderPreset] = &[ ProviderPreset::new("my_gateway", Kind::Chat, "公司内网网关", Some("https://llm.corp.example/v1")) .with_group(GROUP_CHINA) .with_hint("内网专用,出差时连不上") .with_models("qwen-max", &[ModelOption::plain("qwen-max")]), ]; let list: Vec = PresetCatalog::new() // 从本库的全部预置起步 .remove(&["groq", "xai"]) // 本应用不想露出的 .retain(|p| p.kind == Kind::Chat) // 只要对话 .extend(LOCAL) // 加上自己的 .build(); ``` | 方法 | 作用 | |---|---| | `new()` | 从本库全部预置起步(只含本次编译开启的能力) | | `empty()` | 从空目录起步(完全自定义,一般用不到) | | `remove(&[key…])` | 按 key 删,不认识的 key 忽略 | | `retain(条件)` | 只留满足条件的 | | `map(函数)` | 逐条改(如给没地址的条目补默认地址) | | `extend(&[…])` | 加入自己的条目,规则见下 | | `build()` | 得到最终目录,顺序即下拉顺序 | | `vendors(&[kind…])` | 按厂商聚合当前目录(服务商卡片用) | ### extend 插在哪 * **key 与已有条目相同 → 原位覆盖**。想改某家内置预置的模型清单或说明时用它 * 否则插到**同能力、同分组**的最后一条之后 —— 下拉的分组标题不会被切成两段 * 该能力里没有这个分组 → 插到该能力的最后;该能力一条都没有 → 追加到末尾 ## 写自己的预置 `ProviderPreset` 带 `#[non_exhaustive]`,应用不能直接写结构体字面量。 用这组 `const fn` 构造器,照样能写成静态表: | 方法 | 作用 | 缺省值 | |---|---|---| | `new(key, kind, label, base_url)` | 最小构造 | — | | `with_vendor(id)` | 服务商聚合 id(同一家多种能力共用) | = key | | `with_group(GROUP_…)` | 下拉分组 | 「本地 / 自建」 | | `with_hint(text)` | 下拉第二行的要点 | 无 | | `with_models(默认, &[…])` | 默认模型与候选清单 | 空 | | `with_protocol(Protocol::…)` | 对话协议 | OpenAI 兼容 | | `with_match_hosts(&[…])` | 从已存地址反推是哪条预置 | 空 | | `with_extra_fields(&[…])` | 需要用户填的专有字段 | 空 | | `with_default_extra(&[…])` | 预置定死的配置(见下) | 空 | | `with_apply_url(url)` | 密钥申请页 | 无 | | `local()` | 标记为本机服务(需用户先把服务跑起来) | 否 | 构造器不设 i18n key(`label_key` 为空)。接了多语言的界面遇到空 key 应直接显示 `label`。 ::: tip key 别和内置预置重名 重名就是原位覆盖 —— 通常不是本意。建议在应用里写一条测试: 逐条断言 `ai_profile::preset_by_key(p.key).is_none()`。 ::: ## default\_extra:预置定死的配置 有的服务商需要**显式指定协议**。比如一个 New API 视频中转站,地址看上去和别家毫无区别, 本库也不按域名猜品牌 —— 那就让预置带上标记: ```rust ProviderPreset::new("my_relay_video", Kind::Video, "某中转站 视频", Some("https://relay.example.com/v1")) .with_group(GROUP_CHINA) .with_default_extra(&[("video_api", "newapi")]) .with_models("doubao-seedance-2.0", &[ModelOption::plain("doubao-seedance-2.0")]), ``` 用户用这条预置新建配置时,**应用负责把这些键值并进供应商的 `extra`**(已有同名键不覆盖)。 之后 `VideoProtocol::detect(endpoint, extra)` 就会按 New API 处理。 | | `extra_fields` | `default_extra` | |---|---|---| | 谁决定值 | 用户填 | 预置定死 | | 典型用途 | 火山语音的 `appid` | 指定协议 `video_api = newapi` | | 线格式 | `[{ key, label, placeholder, required }]` | `[[key, value], …]` | ::: warning 已存的旧配置要自己补 如果你的应用以前靠别的方式识别(例如按域名),换成 `default_extra` 后, **已经存下的配置里没有这个标记**。写一次数据迁移补上,否则它们会按兜底协议去调、必然失败。 ::: ## 服务商卡片 按厂商聚合(「硅基流动:对话 / 生图 / 视频 / 配音,一个密钥」)要跟着你定制后的目录走: ```rust let catalog = PresetCatalog::new().extend(LOCAL); let cards = catalog.vendors(&[Kind::Chat, Kind::Image]); // 或者对已经 build 出来的列表 let cards = ai_profile::preset::vendors_in(&list, &[Kind::Chat]); ``` `vendors()`(不带 `_in`)聚合的是本库的全量预置,看不到你的私有条目。 ## 实例 | 应用 | 定制方式 | |---|---| | StoryLoom | `extend` 加一家合作渠道的对话 / 生图 / 视频 / 配音四条,视频那条带 `video_api = newapi` | | 一站通 | `retain` 只留自己调用实现支持的协议(OpenAI 生图、火山方舟视频、OpenAI 配音),其余藏掉 | ## 相关章节 * [预置与服务商目录](/api/preset) —— 预置的字段含义 * [生图、视频与配音](/api/media) —— 协议识别规则 * [加一家服务商](/reference/add-provider) —— 该进本库的公共服务商怎么提 PR --- --- url: https://ai-profile.ruoyi.plus/api/protocol.md --- # ai.profile 协议 `ai.profile` 是一个跨应用的模型服务配置交换格式。用户在 A 应用配好一条服务, 复制一段 JSON,粘进 B 应用就能用。 任何工具都可以生成或解析它 —— 它是一个格式约定,不依赖本 crate。 ## 信封 ```json { "kind": "ai.profile", "v": 1, "data": { "name": "我的 DeepSeek", "provider": "openai", "baseURL": "https://api.deepseek.com/v1", "apiKey": "sk-...", "model": "deepseek-flash", "hints": { "toolId": "claude-code" } } } ``` | 字段 | 必填 | 说明 | |---|---|---| | `kind` | ✅ | 固定 `"ai.profile"` | | `v` | ✅ | 协议版本,当前 `1` | | `data.name` | | 配置名;缺失时接收方可用 provider 名兜底 | | `data.provider` | | 来源软件的 provider 标识,用于推断协议 | | `data.baseURL` | | 端点地址;缺失 = 走接收方的默认端点 | | `data.apiKey` | | 明文密钥 | | `data.model` | | 模型 id;**缺失是常见情况**,见下 | | `data.hints.toolId` | | 来源工具标识,协议推断的兜底依据 | ## 🔴 字段命名:规范一种,接受三种 规范写法是 `baseURL` / `apiKey`(`URL` 全大写是历史既成事实,不是笔误)。 但现实中各家生成的 JSON 并不统一,所以**解析时同时接受**: | 规范 | 也接受 | |---|---| | `baseURL` | `baseUrl`、`base_url` | | `apiKey` | `api_key` | 这不是洁癖问题。调研四份既有实现时发现,其中一份的 Rust 解析器只认 `baseURL` / `base_url`,**漏了 `baseUrl`** —— 另一个应用生成的配置它解析不了, 而两边都自认为"实现了 ai.profile"。用户看到的是"复制过去没反应", 两边的开发者都会觉得是对方的问题。 统一到本 crate 正是为了消掉这类静默不兼容。 ::: tip 宽进严出 解析时接受各种别名,**生成时只产出规范写法**。 如果每个实现都"顺手"生成自己偏好的拼写,生态会持续分裂下去。 ::: ## 解析 ```rust use ai_profile::{parse_profile, ParseError}; // default_model:来源没给 model 时的兜底值,按你的预置传入 let profile = parse_profile(pasted_text, "deepseek-flash")?; println!("{} → {}", profile.name, profile.base_url); if profile.model_fallback { // 🔴 来源没给 model,当前值是你补的默认值 —— 提示用户确认 } ``` ### ParsedProfile | 字段 | 说明 | |---|---| | `name` | 配置名;来源没给时为空串 | | `protocol` | **推断出的**协议,决定走 `/v1/messages` 还是 `/v1/chat/completions` | | `raw_provider` | 来源给的原始 provider 字符串(原样保留,便于让用户确认) | | `base_url` | 端点地址;空串 = 来源没给 | | `api_key` | 明文密钥;空串 = 来源没给 | | `model` | 模型 id | | `model_fallback` | 🔴 为真表示**来源没给 model**,当前值是你补的默认值 | 序列化为 camelCase(`baseUrl` / `apiKey` / `modelFallback` / `rawProvider`), 可直接从 Tauri Command 返回给粘贴导入表单。 ::: danger ParsedProfile 含明文密钥 它序列化后会经 IPC 到达前端 —— 这是粘贴导入功能本身的要求(表单要把密钥填进去)。 但因此:**不要把整个结构体写进日志**,也不要存进任何缓存。 ::: ### 为什么 model 不是必填 不少来源分享的是「中转站的 key + 端点」,`model` 留空让接收方自己选 —— 因为中转站暴露的模型名往往与官方不同,来源方自己也不确定接收方该填哪个。 `model_fallback` 就是为这种情况准备的:为真时**提示用户确认模型名**。 猜错的话要等第一次对话才报错,那时用户早已离开设置页,很难联想到根因。 ## 协议推断 `provider` 字符串到协议的映射,外加**两条兜底**。这两条都来自真实场景,不是防御性编程: | 依据 | 判定 | |---|---| | `provider` 含 `anthropic` / `claude` | Anthropic | | `model` 以 `claude-` 开头 | Anthropic | | `hints.toolId` 含 `claude` / `anthropic` | Anthropic | | 其余 | OpenAI 兼容 | **第 2 条**:密钥限定 `/v1/messages` 的中转卖家,`provider` 常写 `custom`, 但 model 就是 `claude-opus` / `claude-sonnet`。 **第 3 条**:某些软件分享中转配置时是 `provider: "custom"` + 空 model + `toolId: "claude-code"`。不看 `toolId` 会误判成 OpenAI 兼容,然后请求错端点 —— 用户得到的是一个 404,完全看不出是协议判错了。 ## 多条打包 一次分享多条配置用独立的 `kind`: ```json { "kind": "ai.profile.bundle", "v": 1, "data": { "profiles": [ { …单条的 data… } ] } } ``` 用独立 `kind` 而不是往单条里塞数组:只认单条的旧实现遇到它会报「不是 ai.profile」, 而不是把第一条误读成一条配置、悄悄丢掉其余的。 ### 解析:`parse_profiles` 单条与打包统一返回列表,调用方不必先判断是哪种: ```rust use ai_profile::parse_profiles; let r = parse_profiles(pasted_text, "")?; // r.profiles:Vec,顺序与来源一致;单条信封时恰好一条 // r.skipped:跳过的条数(见下)—— 界面要告诉用户,否则会以为漏导了 // r.bundle:来源是不是打包 ``` `parse_profile`(单条)签名不变,遇到打包仍报 `not_ai_profile`。 ### 兼容已经发出去的写法 智码(tauri-cc)的打包在本协议定稿前就已经在用,形状是它同步载荷里的档案: | 智码写法 | 处理 | |---|---| | `data.api_profiles` | 与规范的 `data.profiles` 同等对待 | | snake\_case 字段(`base_url` / `api_key`) | 同单条,三种拼写都认 | | 条目顶层的 `tool_id` | 作用同 `hints.toolId`,参与协议推断 | | `auth_type: "oauth"` | **跳过并计入 `skipped`** —— OAuth 凭据与签发它的设备绑定,换一台机器不可用 | | `manifest` 及其它多余字段 | 忽略 | 不是对象的条目静默丢弃;一条可导入的都没有时返回 `EmptyBundle`。 ::: tip 为什么不另起一套 生态里已经有软件在发这个格式。另起一套的结果是两种打包并存、互相解析不了 —— 宽进严出:解析时照收别家已发出去的写法,将来生成打包时只产出规范的 `profiles`。 ::: ## 生成 ```rust use ai_profile::{to_profile, Protocol}; let json = to_profile( "我的 DeepSeek", Protocol::OpenAiCompatible, "https://api.deepseek.com/v1", "sk-...", "deepseek-flash", ); // 写进剪贴板,或存成文件 ``` 输出的 `provider` 用通用标签(`"openai"` / `"anthropic"`)而非内部枚举名 —— `"openai"` 比 `"openai_compatible"` 更容易被别家实现认出来。 ::: danger 产物含明文密钥 `to_profile` 的返回值里有密钥原文。调用方自己决定它去哪(剪贴板 / 文件), 并且**不要写进日志**。本 crate 不做任何持久化。 建议在界面上明确告知用户「这段内容包含你的密钥」,别让他随手发到群里。 ::: ## 版本兼容 **只拒绝更高的版本**: ```rust if env.v > AI_PROFILE_VERSION { return Err(UnsupportedVersion { .. }) } ``` 低版本能被高版本实现读懂(字段只增不改),拒绝低版本只会把老软件分享的配置挡在外面。 `v` 字段缺失时按当前版本处理 —— 有些手写的配置会漏掉它。 ## 错误 `ParseError` 回答的是「这段文本是不是一条配置」,与 `VerifyError`(「这条配置对不对」) 分属两个阶段: | 变体 | JSON `code` | 含义 | |---|---|---| | `Empty` | `empty` | 输入为空 | | `InvalidJson { detail }` | `invalid_json` | 不是合法 JSON | | `NotAiProfile { found }` | `not_ai_profile` | `kind` 不是 `ai.profile` | | `UnsupportedVersion { found, supported }` | `unsupported_version` | 版本高于本实现 | | `MissingData` | `missing_data` | `data` 缺失或不是对象 | | `EmptyBundle { skipped }` | `empty_bundle` | 打包里没有一条可导入的配置(`skipped` 为跳过的 OAuth 档案数) | 界面上这两类错误的表现应当不同:`ParseError` 说明"你粘错东西了", `VerifyError` 说明"配置本身有问题"。 ## 给其它实现者 如果你在别的语言里实现 `ai.profile`,请遵守: 1. **解析接受三种拼写**(`baseURL` / `baseUrl` / `base_url`;`apiKey` / `api_key`) 2. **生成只产出规范写法** 3. **只拒绝更高版本** 4. `model` 缺失是合法的,别当成错误 5. 多条打包用 `ai.profile.bundle`,并照收 `api_profiles` 这个既有写法 6. 字段**只增不改** —— 改已有字段名是生态分裂 以上约定都有对应的一致性用例,跑通即与 Rust 版行为一致,见[其他语言实现](/reference/spec)。 ## 相关 * [前端对接](/guide/frontend) —— 粘贴导入表单的实现 * [版本策略](/reference/versioning) —— 协议改动的版本位判定 --- --- url: https://ai-profile.ruoyi.plus/api/endpoint.md --- # 端点与模型清单 两个纯函数模块,不需要 `client` feature,也不碰网络。 ## 端点拼接 ### 核心契约:base\_url 原样使用 ```rust use ai_profile::endpoint::join_api_path; assert_eq!( join_api_path("https://api.deepseek.com/v1", "models"), "https://api.deepseek.com/v1/models" ); ``` **写什么就是什么,版本段由用户自己填。** 预置清单里各家都已带上完整版本段。 唯一的例外是 Anthropic 协议,见下方[「Anthropic 协议自动补 /v1」](#anthropic-协议自动补-v1)。 ### 为什么不自动补 /v1 这里曾经是推断式的:「末段是 `v<数字>` 就不补、否则补 `/v1`、末尾 `#` 可以强制不补」。 推断看着聪明,但它的例外一直在变多: | 情况 | 实际地址 | |---|---| | 多数服务商 | `/v1` | | 智谱 | `/v4` | | Gemini OpenAI 兼容层 | `/v1beta/openai` —— 版本段**不在末尾** | | 各类中转站 | `/api/openai/v1`、`/proxy/anthropic` … | Gemini 那条只能靠末尾 `#` 开后门。**「需要一个转义符才能表达的规则」本身就说明规则不对。** 而推断错的代价是隐性的:用户照着服务商文档填了正确的 base\_url, 库悄悄加了一段,他只看到 404 —— 而且第一反应是怀疑自己填错了, 根本不会想到是库擅自改了地址。 ::: tip 这条契约有守卫测试 `join_api_path_never_infers_version_segment` 与 `openai_side_still_never_infers` 盯着,防止有人日后"好心"把 OpenAI 兼容一侧的推断加回来。 ::: ### 仅有的两处容错 ```rust // 1. 误填了完整对话端点 —— 剥回 base join_api_path("https://api.x.com/v1/chat/completions", "models") // → "https://api.x.com/v1/models" // 2. 旧契约遗留的末尾 # 标记(存量配置里可能有) join_api_path("https://api.x.com/v1#", "models") // → "https://api.x.com/v1/models" ``` 第 1 条对应一个很常见的用户行为:「同一个字段填了两种东西」—— 有人把文档里的完整请求地址整个粘进来。 ### join\_chat\_endpoint:只对对话端点成立的捷径 ```rust use ai_profile::endpoint::join_chat_endpoint; // 用户粘了完整端点 → 原样使用 join_chat_endpoint("https://relay.example/v1/chat/completions", "chat/completions") // → "https://relay.example/v1/chat/completions" ``` `path` 传 `"chat/completions"`(OpenAI 兼容)或 `"messages"`(Anthropic)。 ::: warning 为什么它和 join\_api\_path 是两个函数 「获取模型」走的是 `/models`。如果共用一套判断, 用户填了完整的 `/chat/completions` 会让它拿这个地址去 GET,必然失败。 表现是「能聊天却拉不到模型列表」—— 一个很难联想到根因的故障。 ::: ### Anthropic 协议自动补 /v1 「不推断版本段」的理由只对 OpenAI 兼容一侧成立 —— 那边各家确实不统一。Anthropic 协议没有这个问题: 它**只有 v1**,而且整个生态都约定 base 填到版本段之前、由客户端补 `/v1/messages` (官方 SDK、Claude Code 的 `ANTHROPIC_BASE_URL`、各家的 Anthropic 兼容入口)。 所以 Anthropic 协议按这个约定补,是确定的协议翻译,不是猜。 ```rust use ai_profile::endpoint::{anthropic_base_url, join_chat_endpoint}; // 只填到主机名(Claude Code 的习惯)→ 补 /v1 join_chat_endpoint("https://relay.example:8443", "messages") // → "https://relay.example:8443/v1/messages" // 自己填了 /v1 → 原样用,不会补成 /v1/v1 join_chat_endpoint("https://api.anthropic.com/v1", "messages") // → "https://api.anthropic.com/v1/messages" // Anthropic 兼容入口 anthropic_base_url("https://api.deepseek.com/anthropic") // → "https://api.deepseek.com/anthropic/v1" // 末尾 # = 别替我补(留给路径特殊的网关) anthropic_base_url("https://odd.gateway/raw#") // → "https://odd.gateway/raw" ``` 两种写法用户都能用,填不填 `/v1` 结果一样。「获取模型」(`Verifier`)对 Anthropic 协议走同一规则, 两边口径一致,不会出现「获取通过、对话 404」。 ::: warning 不补时踩过的坑 从别的工具粘来 `https://x.com:8443` 这种地址,对话打到 `/messages`, 中转网关(如 Sub2API)对不认识的路径回 **200 + 前端网页**,报出来是「解析失败」,完全看不出是少了 `/v1`。 而它的 `/models` 不带 `/v1` 也能用 —— 于是「获取模型」是绿的,只有对话挂。 ::: ### ends\_with\_version\_segment ```rust use ai_profile::endpoint::ends_with_version_segment; assert!(ends_with_version_segment("https://api.deepseek.com/v1")); assert!(ends_with_version_segment("https://open.bigmodel.cn/api/paas/v4")); assert!(!ends_with_version_segment("https://api.deepseek.com")); assert!(!ends_with_version_segment("https://v4.example.com")); // 这是主机名 ``` 只认「`v` + 全数字」,所以主机名以 `v4.` 开头的不会被误判。 **端点拼接不用它** —— 保留是为两件事:守住「预置 base\_url 必须自带版本段」这条契约, 以及给前端「这个地址看着缺版本段」的提醒保持同一口径。 ## 模型清单清洗 端点返回的 `/models` 往往混着向量、重排、语音、OCR 等非对话模型。 聚合平台尤其明显 —— 硅基流动一次能返回上百条,OpenRouter 实测 433 条。 ```rust use ai_profile::model_filter::{clean_fetched_models, is_chat_model_id}; let cleaned = clean_fetched_models(ids); println!("{} 个可用,滤掉 {} 个", cleaned.models.len(), cleaned.dropped); ``` `dropped` 用于「已滤掉 N 个向量 / 重排 / 语音等」这类提示文案 —— 让用户知道清单被处理过,而不是以为端点就这么几个。 ### 排除法,不是白名单 判断依据是**特征词排除**(`embed`、`rerank`、`whisper`、`tts`、`image`、`ocr`…), 而不是「认识的模型才留」。 理由:厂商上新速度远快于特征词更新。白名单必然把新模型误藏, 而\*\*"藏起来"对用户是不可见的\*\* —— 他只会觉得"这个端点怎么没有那个模型", 不会想到是客户端滤掉了。排除法最多漏掉几个该滤的,代价小得多。 ### 全被滤光时原样返回 ```rust // 🔴 如果过滤后一个不剩,返回去重后的原始清单,并把 dropped 记 0 ``` 那说明这套特征词在这个端点上判错了。此时宁可把原始清单摆给用户看, 也不能给他一个空下拉 —— **增强而非依赖**:任何一步出错都不该让用户卡在"选不了模型"。 ### CleanedModels | 字段 | 说明 | |---|---| | `models` | 可用于对话的清单(已去重) | | `dropped` | 被滤掉的条数 | | `dropped_models` | 被滤掉的模型 id(端点顺序),`len() == dropped` | `is_chat_model_id(id)` 是单条判断,可以单独使用。 ## 相关 * [连通性验证](/api/verify) —— `verify` 会自动调用清洗 * [服务商清单](/reference/providers) —— 各家的 base\_url 实际长什么样 --- --- url: https://ai-profile.ruoyi.plus/api/verify.md --- # 连通性验证 需要 `client` feature。 验证只打端点的**模型列表**接口 —— 它回答「这个地址 + 这个密钥能不能用」, 并顺带把真实模型清单带回来,**不产生任何生成费用**。 ## 为什么验证和试运行要分开 四种能力的单次调用成本差几个数量级: | 能力 | 一次真实调用的代价 | |---|---| | 对话 | 几十 token | | 生图 | 几毛钱 | | 视频 | 约 ¥1–5,且要等 90 秒 | 一个「测试」按钮如果对所有能力都做真实调用,用户点一下视频测试就是实打实的钱和一分半等待。 所以本库只把**零成本验证**(本页)做成通用接口。需要真实调用时: | 能力 | 做法 | |---|---| | 对话 | 用应用自己的对话实现发一句 ping(请求格式、鉴权头都是应用的),`max_tokens` 别给 1 —— 推理模型可能直接报错 | | 生图 / 配音 | 直接调 [`generate` / `synthesize`](/api/media),就是试运行 | | 视频 | 不提供 —— 单次约 ¥1–5、要等 90 秒,让用户在真实业务里验证 | ## Verifier `Verifier` 持有一个可复用的 `reqwest::Client`。**建一次、存起来、反复用。** ```rust use ai_profile::client::Verifier; let verifier = Verifier::new()?; // 应用启动时 ``` ::: danger 不要在每次验证时现建 `reqwest::Client` 内部持有连接池,这是它的全部价值。每次现建等于连接池永远是空的, 每次都从 TCP + TLS 握手重来 —— 跨境端点尤其明显。 「全部测试」这种一次点四下的按钮,会连做四次完整握手。 ::: ### 注入代理与自定义配置 ```rust use ai_profile::client::Verifier; let builder = my_app::proxy::apply_proxy(ai_profile::reqwest::Client::builder()); let verifier = Verifier::from_builder(builder)?; ``` 用本 crate 重导出的 `ai_profile::reqwest` 建 builder —— 版本必然匹配。 用自己依赖树里的 reqwest 会在版本不同时报出 `expected ClientBuilder, found ClientBuilder` 这种极难懂的错误。 ::: warning 三项安全默认值你覆盖不掉 `from_builder` 在**你的配置之后**再施加: | 项 | 值 | 为什么不可覆盖 | |---|---|---| | 总超时 | 20 秒 | 用户正看着转圈,交互式动作的预算比对话请求短得多 | | 连接超时 | 10 秒 | 同上 | | 重定向 | **禁止** | 跨 host 跳转时 reqwest 只剥 `Authorization` 等标准头、**不剥自定义头**,Anthropic 的 `x-api-key` 会被原样发往跳转目标。同时堵死二段跳 SSRF | LLM 端点正常返回 200,不需要重定向。 ::: ### 自由函数 verify 图省事的一次性场景可以直接用自由函数,它走进程级共享的默认实例: ```rust use ai_profile::client::{verify, ServiceConfig}; let ok = verify(cfg).await?; ``` ::: warning 需要代理就不能用它 默认实例没有任何代理配置。桌面应用应当自己建一个 `Verifier::from_builder` 存进全局状态。 ::: ## ServiceConfig 全部字段都是借用 —— 调用方通常直接从表单字段取,不该为验证一次而 clone。 ```rust use ai_profile::client::ServiceConfig; use ai_profile::Protocol; let extra = [("appid", "123")]; let cfg = ServiceConfig::new(Protocol::OpenAiCompatible, "https://api.deepseek.com/v1") .with_preset("deepseek") .with_api_key("sk-…") .with_model("deepseek-flash") .with_extra(&extra); ``` | 方法 | 作用 | |---|---| | `new(protocol, base_url)` | 最小构造 | | `with_preset(key)` | 校验该预置的必填专有字段;`base_url` 为空时用预置地址兜底 | | `with_api_key(key)` | 明文密钥。空串 = 不带鉴权(本地服务常见) | | `with_model(model)` | 给了就校验它在不在端点清单里 | | `with_extra(&[(k, v)])` | 专有字段的实际值 | ::: tip Anthropic 协议的地址 协议是 Anthropic 时,`Verifier` 先按 [Anthropic 约定](/api/endpoint#anthropic-协议自动补-v1)补 `/v1` 再打 `/models`, 与对话端点同一口径 —— 不会出现「获取通过、对话 404」。404 诊断也用补过的地址,不会再建议「补 /v1」。 ::: ::: warning 只能用 builder `ServiceConfig` 带 `#[non_exhaustive]`,外部 crate 写不了 `ServiceConfig { .. }` 字面量(E0639)。 这是刻意的:`non_exhaustive` 让本 crate 以后加字段只算 minor 版本。 代价就是入参类型**必须配完整的 builder**,否则下游根本没法用。 ::: ## VerifyOk ```rust pub struct VerifyOk { pub latency_ms: u32, // 往返耗时 —— 界面显示「正常 · 320ms」,中转站慢不慢一眼看出 pub models: Vec, // 端点返回的可对话模型清单(已去重 + 清洗) pub dropped: usize, // 滤掉的条数 —— 用于「已滤掉 N 个向量 / 重排 / 语音等」 pub dropped_models: Vec, // 滤掉的模型 id(端点顺序),配置同时挂生图 / 配音模型时用 pub model_in_list: bool, // 当前填的 model 在不在清单里 pub limits: Option, // 当前模型的限额(端点上报) pub model_limits: Vec<(String, TokenLimits)>, // 全部报了限额的模型 } ``` 序列化为 camelCase(`latencyMs` / `modelInList`),可直接从 Tauri Command 返回。 `dropped` 别忽略:聚合平台一次能返回几百条,滤掉的往往比留下的多。 不告诉用户清单被处理过,他会以为这个端点就这么几个模型。 `models` 的正确用法是**把它填进模型下拉** —— 用户不必再去翻文档抄模型名。 这是验证顺带产生的价值,别浪费。 `model_in_list` 为 `false` 时给一个提示而不是报错:端点清单未必完整, 用户也可能刻意用一个未公开的模型名。 ## token 限额:分层回退 `limits` 与 `model_limits` 带的是**端点自己报的**上下文窗口与输出上限。 ### 为什么不做一张全量能力表 调研过 models.dev(一万多次提交在维护)与 LiteLLM 的 `model_prices_and_context_window.json`。那类数据周级变动,且**同一个模型在 不同中转站的实际限额并不相同** —— 本库既没有那个维护量,也无从知道某家中转站 到底给用户开了多大窗口。真要全量表,应用自己去拉 `models.dev/api.json`。 本库只回答一个具体问题:**发这次请求前,该按多大的窗口裁历史。** ### 静态兜底不是可选项 实测各家 `/models` 到底报不报: | 端点 | 带上下文? | |---|---| | OpenRouter | ✅ `context_length` 100% 覆盖(442 条实测) | | DeepSeek | ✅ `context_window` + `max_output_tokens`(真实密钥实测) | | LM Studio / Ollama 兼容层 | ❌ 只有 `{id, object, owned_by}`(原生 `/api/v1/models` 才有) | 根因是 **OpenAI 的 `/v1/models` 规范里就没有 context 字段**。只靠端点的话, 这个能力在多数端点上等于不存在。所以分层回退: ``` 0. 用户手填 ← TokenLimits::from_user(),source: User 1. 端点实时上报 ← VerifyOk.limits,source: Endpoint 2. 预置静态兜底 ← preset::model_limits(),source: Preset 3. 都没有 → None ← 让用户手填,别猜 ``` 端点值只在验证时拿得到,**调用方要自己存下来**,对话时再叠: ```rust use ai_profile::{preset, TokenLimits}; let stored = TokenLimits::with_source(saved.context_window, saved.max_output, saved.source); let preset = preset::model_limits(protocol, base_url, model); let limits = match preset { Some(p) => stored.or(p), // 逐字段回退:用户只填了窗口,输出上限照样从预置补 None => stored, }; ``` ::: tip 为什么逐字段 用户常常只知道窗口(文档写了)不知道输出上限。整条替换的话, 填了窗口反而丢掉预置里的输出上限 —— 填了比不填还差。 ::: ### 🔴 来源必须能分辨 ```rust pub struct TokenLimits { pub context_window: Option, pub max_output: Option, pub source: LimitSource, // User | Endpoint | Preset } ``` `source` 不是装饰。调研到的真实故障几乎每条都源于「把猜的数字当成真的」: * 某网关丢元数据 → 客户端回落硬编码表 → 512K 被当成 131K,提前触发压缩 * 某应用钉死 65536,而实测请求中位数 153395、p90 达 433535 * 某扩展硬编码 288K,同时无视服务器上报值**和**用户设置 共同点不是「数字错了」,而是**错了也看不出来**。有了 `source`,界面才能区分 「端点上报 128K」与「预估 128K,可修改」。 ::: warning top\_provider 优先级更高 OpenRouter 的顶层 `context_length` 是**模型本体**标称值, `top_provider.context_length` 是**这家实际提供**的 —— 实测两者会不一致。 取错了会按一个用不到的大数字裁历史,表现为「明明裁过还是超限」。 本库优先取嵌套那份。 ::: ### 算裁剪预算 ```rust use ai_profile::TokenLimits; // reserve_output 传本次请求实际要用的 max_tokens,不是模型的输出上限 —— // 用上限会把预算压得过小 let budget = limits.input_budget(4096); // Some(123_904) 当窗口是 128K // 🔴 窗口未知时返回 None:不猜。 // 调用方应当据此**不裁**,而不是自己兜一个默认值 —— // 那正是上面那些故障的成因。 ``` ### 静态值只填文档明确的 预置里的 `ModelOption` 可带 `context_window` / `max_output`,但**只填官方文档 写明的**,查不到一律留空。 顺带一个数据:DeepSeek V3 时代是 128K/8K,V4 已经 **1M/384K** —— 半年翻八倍。 这正是「端点报了就用端点的」的理由,静态值只是端点沉默时的下限保证。 ## 并发验证 `verify` 只借 `&self`,且 `Verifier` 是 `Send + Sync + 'static`: ```rust use futures_util::future::join_all; let results = join_all(rows.iter().map(|r| { let v = &verifier; async move { (r.id, v.verify(make_cfg(r)).await) } })).await; ``` 共用一个 `Verifier` 就是共用连接池 —— 同一家的多条配置只握手一次。 本库自己的 `cargo xtask probe` 就是这么做的:25 家串行最坏要 8 分钟, 并发后实测 12 家 4.5 秒跑完。 ## 不发请求也能判的事 有些失败不必等网络往返。**在发请求之前**判出来,界面直接禁用按钮: ```rust use ai_profile::client::check_required_fields; let extra = [("appid", "")]; if check_required_fields(Some("some_preset"), &extra).is_err() { // 「测试连接」按钮置灰 } ``` 禁用态优于「点了才报错」—— 用户不会浪费一次点击,也不会怀疑是网络问题。 ## 可单测的纯函数 错误映射是本模块最容易写错的地方,所以判断逻辑与发请求是分开的。 这些函数**不碰网络**,你也可以直接用: | 函数 | 作用 | |---|---| | `diagnose(status, body, requested_url, base_url)` | HTTP 状态码 + 响应体 → `VerifyError` | | `suggest_url(base_url)` | 404 时推断正确地址;已有版本段时返回 `None` | | `check_required_fields(preset_key, extra)` | 必填专有字段校验 | | `parse_model_ids(body)` | 从 `/models` 响应抽 id,容错裸数组格式 | `suggest_url` 只在**确实看不到版本段**时给建议: ```rust use ai_profile::client::suggest_url; assert_eq!(suggest_url("https://api.deepseek.com").as_deref(), Some("https://api.deepseek.com/v1")); assert_eq!(suggest_url("https://api.deepseek.com/v1"), None); // 已有 /v1 assert_eq!(suggest_url("https://open.bigmodel.cn/api/paas/v4"), None); // 智谱是 /v4 assert_eq!(suggest_url("https://generativelanguage.googleapis.com/v1beta/openai"), None); ``` 已经有版本段却 404 说明是别的问题 —— 乱给建议会把用户引向另一个错误答案。 ## 安全约定 * **密钥绝不进错误信息**:所有 `detail` 字段只放端点返回的文本摘要(截断 300 字符), 调用方可以安全地 `log::warn!("{e}")` * **不持久化任何东西**:密钥用完即弃 * **禁重定向**:见上方 ## 相关 * [错误码对照](/reference/errors) —— 七个变体各自对应的界面动作 * [Tauri 应用接入](/guide/tauri-integration) —— 存进 AppState 的完整示例 --- --- url: https://ai-profile.ruoyi.plus/api/limits.md --- # 限额:上下文窗口与输出上限 `limits` 模块只回答一个问题:**发这次请求之前,该按多大的窗口裁历史、`max_tokens` 最多给多少?** 它刻意**不是**一张全量模型能力表。那类数据周级变动,同一个模型在不同中转站的实际限额也不一样 —— 真要全量表,去拉 models.dev 这类专门的项目。 ## TokenLimits ```rust pub struct TokenLimits { pub context_window: Option, // 上下文窗口(输入 + 输出总量),用来裁历史 pub max_output: Option, // 单次输出上限,用来给 max_tokens 封顶 pub source: LimitSource, // 这两个数字是谁给的 } ``` 两个数字都是 `Option`:**拿不到就是拿不到,不填一个猜的数字**。 ## 四层来源 | 优先级 | 来源 | 构造 | 可信度 | |---|---|---|---| | 0 | 用户手填 | `TokenLimits::from_user` | 用户说了算 —— 中转站另有限制时唯一的出口 | | 1 | 端点上报 | `TokenLimits::from_endpoint` | 最准:中转站报的就是它自己的真实限额 | | 2 | 预置静态值 | `ModelOption::preset_limits` / `preset::model_limits` | 保守兜底,可能过时,应允许用户改 | | 3 | 都没有 | `None` | 让用户填,别猜 | 🔴 **来源必须能被界面分辨**。调研过的真实故障几乎都是「把猜的数字当成真的」—— 共同点不是数字错了,而是**错了也看不出来**。所以 `source` 是必填字段, 界面可以据此显示「端点上报 128K」还是「预估 128K,可修改」。 ### 为什么多数端点不报 OpenAI 的 `/v1/models` 规范里**就没有**上下文字段。实测: | 端点 | `/models` 带不带限额 | |---|---| | OpenRouter | ✅ 全覆盖,还有「当前服务商实际提供」的那一份 | | DeepSeek | ✅ `context_window` + `max_output_tokens` | | LM Studio / Ollama 的兼容层 | ❌ 只有 `{id, object, owned_by}` | 所以预置静态值不是可选项 —— 不做的话,多数端点上这个能力等于不存在。 ## 逐字段合并 ```rust use ai_profile::TokenLimits; let user = TokenLimits::from_user(Some(64_000), None); // 用户只知道窗口 let preset = TokenLimits::from_preset(Some(128_000), Some(8192)); let l = user.or(preset); assert_eq!(l.context_window, Some(64_000)); // 用户的 assert_eq!(l.max_output, Some(8192)); // 预置补的 ``` `or` 是**逐字段**回退:用户常常只知道窗口大小(文档写了),不知道输出上限。 整条替换的话,填了窗口反而丢了预置里的输出上限。`source` 取优先级最高、真正起作用的那一层。 典型的三层叠法: ```rust use ai_profile::{preset, Protocol}; let preset_l = preset::model_limits(Protocol::OpenAiCompatible, Some(&base_url), &model); let effective = [user_l, endpoint_l, preset_l] .into_iter() .flatten() .reduce(|hi, lo| hi.or(lo)); ``` ## 端点上报从哪来 「获取模型」([连通性验证](/api/verify))已经发了 `/models` 请求,限额顺带解析出来,不多花一分钱: | `VerifyOk` 字段 | 内容 | |---|---| | `limits` | **当前填的那个模型**的限额;端点不报时为 `None` | | `model_limits` | 端点上报了限额的全部模型 `(id, 限额)` —— 切换模型时不必再打一次端点 | 各家字段名不统一,`parse_model_limits` 按固定顺序依次尝试(`context_length`、`context_window`、 `max_input_tokens`……),OpenRouter 优先取「当前服务商实际提供」的那一份。 ## 用起来 ### 裁历史的预算 ```rust let budget = l.input_budget(max_tokens); // 窗口 − 本次要用的 max_tokens;窗口未知返回 None ``` `reserve_output` 传**本次请求实际要用的** `max_tokens`,不是模型的输出上限 —— 用上限会把预算压得过小。 直接喂给历史裁剪用 `history::history_budget`,它会再扣掉系统提示与工具定义的余量,见[历史裁剪与超长重试](/api/history)。 ### 给 max\_tokens 封顶 ```rust let cap = l.max_output.map_or(requested, |m| requested.min(m)); ``` 已知上限只用来**收窄**,别把请求放大到模型标称的最大值 —— 聚合站可能按上限预扣额度,超长生成也更容易超时。 ### 持久化 `LimitSource::as_str` / `parse` 与序列化格式同一套拼写(`user` / `endpoint` / `preset`)。 🔴 **预置来源不要落库** —— 它随本库版本更新,存下来就冻结在旧值上了;每次现查即可。 ## 相关章节 * [连通性验证](/api/verify) —— 端点上报限额的来源 * [历史裁剪与超长重试](/api/history) —— 预算的消费方 * [预置与服务商目录](/api/preset) —— 预置里的静态限额 --- --- url: https://ai-profile.ruoyi.plus/api/history.md --- # 历史裁剪与超长重试 `history` 模块回答一个问题:**对话越聊越长时,怎么不让请求超出模型的上下文窗口。** 不依赖 `client` feature,纯函数,移动端也能用。 ## 为什么需要 前端驱动工具循环的应用(每轮把全部历史交给后端)会一直往历史里追加: assistant 的工具调用、工具返回的结果。工具结果往往很大 —— 一次 SSH 命令输出、一段审计日志、 一个文件的内容。聊到一定长度,请求必然超出窗口,用户收到一个看不懂的 HTTP 400。 ## 两道防线 | 场景 | 做法 | |---|---| | 窗口已知 | 发送前主动裁:`history_budget` → `trim_history` | | 窗口未知 | **不猜**,照常发;服务端报超长(`is_context_overflow`)后按 `retry_budget` 裁一半重试 | 第二道是为自定义中转准备的:它们多数不在 `/models` 里报限额,只靠第一道的话 这类配置永远不裁,直到每一轮都 400。被动重试不需要知道窗口大小 —— 服务端的报错就是事实。 ## 接入:实现 HistoryMessage 消息类型各应用自己定义,只要实现两个方法: ```rust use ai_profile::history::HistoryMessage; use serde_json::Value; impl HistoryMessage for ChatMessage { fn role(&self) -> &str { &self.role } fn content(&self) -> &Value { &self.content } } ``` 内容按 Anthropic 风格理解:纯文本是字符串,否则是 content block 数组 (`text` / `tool_use` / `tool_result`)。 ## 主动裁剪 ```rust use ai_profile::history::{history_budget, trim_history}; // limits 来自 TokenLimits 分层合并(见「连通性验证」一页的限额一节) let budget = history_budget(limits.as_ref(), max_tokens); // 窗口 − max_tokens − 8192 余量 let out = trim_history(messages, budget); // budget 为 None 时原样返回 // out.messages 可直接发出;out.dropped 要显示给用户 ``` 裁剪规则: 1. 从最新往回累加,超预算就停 —— 最近的上下文最有价值 2. **不切断 tool\_use / tool\_result 配对**:要么一起留,要么一起丢 3. 第一条 user 消息(任务描述)预留位置补回 —— 但只在它不超过预算一半时 ::: warning 为什么首条消息有条件 用户第一条就贴了一大段日志时,无条件补回会让结果永远超预算:主动裁剪降不下来, 被动重试又因「没有可裁的」而停下,会话从此每一轮都 400。 ::: ::: danger dropped 必须显示给用户 「模型不记得前面说过的话」如果没有任何提示,用户只会觉得它变笨了,而且查不出原因。 ::: ## 被动重试 ```rust use ai_profile::history::{is_context_overflow, retry_budget, trim_history}; // 在拿到非 2xx 响应的地方识别,用一个专门的错误类型把信号带出去 if is_context_overflow(status, &body) { return Err(MyError::ContextOverflow(msg)); } // 外层循环:最多 3 次,裁无可裁就停 for attempt in 0..3 { match send(&req).await { Err(MyError::ContextOverflow(_)) => { let budget = retry_budget(&req.messages); // 当前估算的一半 let out = trim_history(std::mem::take(&mut req.messages), Some(budget)); if out.dropped == 0 { break; } // 再试只会重复同一个失败 req.messages = out.messages; } other => return other, } } ``` 重试要发生在**向前端推送任何流式事件之前** —— 超长报错在 HTTP 状态检查阶段就会返回, 只要在那里识别,前端就不会看到两段流。 ## 超长识别:宁可漏判,不可误判 误判会把用户的历史无谓地裁掉一半,所以只认明确的超长报错: | 覆盖 | 说明 | |---|---| | OpenAI / DeepSeek / vLLM | `context_length_exceeded`、`maximum context length` | | Anthropic | `prompt is too long` | | Gemini(OpenAI 兼容层) | `exceeds the maximum number of tokens` | | OpenRouter / Kimi / 通义 / 智谱 | 各自的措辞 | | `413` | 请求体过大,裁历史同样有效 | 明确**排除**: * `429` 限流 —— 报错里常出现「tokens per min」,与窗口无关 * 「max\_tokens 太大」—— 那是**输出**上限,裁历史解决不了 ## 估算而非精确计数 token 数按字符估算并刻意偏保守(约 2 字符 / token)。精确计数要引入各家的 tokenizer, 是几 MB 的体积代价,而这里只需要回答「要不要裁」—— 裁多了只是少几轮上下文,裁少了是整个请求失败。 ## 相关 * [连通性验证](/api/verify) —— 限额从哪来(用户 > 端点上报 > 预置 > 未知) --- --- url: https://ai-profile.ruoyi.plus/api/media.md --- # 生图、视频与配音 需要 `client` 加上对应能力的 feature(`image` / `video` / `tts`)。 `media` 模块负责**一次 HTTP 调用怎么发、怎么解析**:生图 `generate`、视频 `submit` / `poll`、配音 `synthesize`。 实现整体来自 StoryLoom 在生产环境跑过的代码,各家协议细节、错误翻译、超时策略都是实测踩出来的。 ## 边界:任务编排归你 | 本模块 | 调用方 | |---|---| | 按地址识别协议、组装请求、解析响应 | 建任务记录、写数据库 | | 生图结果**立即下载**成字节(临时链接常 1 小时失效) | 落盘、生成缩略图 | | 视频的一次提交、一次查询 | **轮询循环**、取消、断点续跑、进度事件 | | 把上游报错翻成可操作的中文 | 决定展示在哪、要不要重试 | 视频尤其如此:轮询间隔、超时、用户点「停止」后怎么办、应用重启后怎么续跑, 都和你的存储与界面绑定,本库不替你决定。 ## 协议按地址识别 各家协议不同,却都只给一个 base\_url,所以协议从**地址**(和供应商的 `extra`)里认。 **改预置地址之前先看这张表** —— 地址决定走哪套协议。 | 能力 | 识别规则 | 函数 | |---|---|---| | 生图 | 含 `dashscope` → 通义万相异步任务;其余 → OpenAI `/images/generations` | `ImageProtocol::detect` | | 视频 | `minimaxi` → 海螺;`dashscope` → Vidu;`bigmodel` / `zhipu` → 智谱;`siliconflow` → 硅基流动;`extra.video_api = "newapi"` → New API 中转站;其余 → 火山方舟 | `VideoProtocol::detect` | | 配音 | 含 `openspeech` → 火山语音专有协议;火山**方舟**地址 → 直接报配置错误;其余 → OpenAI `/audio/speech` | `TtsProtocol::detect` | ::: warning New API 中转站只认显式标记 New API / one-api 是通用的中转架构,站点千千万。本库**不按域名猜** —— 需要走这套协议的配置要在 `extra` 里带 `{"video_api":"newapi"}`。 预置可以用 [`default_extra`](/api/catalog#default-extra-预置定死的配置) 自动写上。 ::: 302.AI 这类聚合站走透传地址(`api.302.ai/minimaxi/v1`),靠路径里的厂商名命中对应协议。 ## 生图 ```rust use ai_profile::media::image::{AnyImageProvider, ImageGenConfig, ImageGenParams}; let provider = AnyImageProvider::from_config(ImageGenConfig { endpoint: "https://api.siliconflow.cn/v1".into(), model: "Kwai-Kolors/Kolors".into(), api_key: key, }); let img = provider .generate(&ImageGenParams { prompt: "竖屏漫画分镜:少女站在雨中的站台".into(), size: "720x1280".into(), ..Default::default() }) .await?; std::fs::write(format!("shot.{}", img.ext), &img.bytes)?; ``` | 参数 | 说明 | |---|---| | `prompt` / `negative_prompt` | 提示词 / 反向提示词 | | `seed` | 同一角色复用同一 seed 求一致性;`None` 由服务端随机 | | `size` | `宽x高`,默认 `720x1280`(9:16 竖屏) | | `images` | 参考图(URL 或 base64 data URL):0 张纯文生图,1 张图生图,多张多图融合 | 返回的 `ImageResult` 已经把图片下载成字节,`ext` 按响应推断(png / jpg / webp)。 小于 `MIN_IMAGE_BYTES`(1 KB)的响应一律当失败 —— 中转站异常时常返回空内容或错误页,却给 200。 ## 视频 视频必然是异步的:提交拿任务号,再轮询到完成。 ```rust use ai_profile::media::video::{AnyVideoProvider, VideoGenConfig, VideoGenParams, VideoProvider, VideoTaskStatus}; let provider = AnyVideoProvider::from_config( VideoGenConfig { endpoint, model, api_key }, &provider_extra, // 供应商的 extra JSON,可为空串 ); let task_id = provider .submit(&VideoGenParams { prompt: "镜头缓慢推进,人物自然眨眼".into(), image: first_frame_data_url, // 首帧 last_image: String::new(), // 尾帧,空 = 纯首帧模式 duration: 5, ..Default::default() }) .await?; // 轮询循环由调用方负责 loop { match provider.poll(&task_id).await? { VideoTaskStatus::Pending => tokio::time::sleep(std::time::Duration::from_secs(5)).await, VideoTaskStatus::Succeeded(url) => break download(url).await?, VideoTaskStatus::Failed(msg) => return Err(explain_error(&msg).into()), } } ``` `AnyVideoProvider` 实现了 `VideoProvider`,可以直接 move 进 `tokio::spawn` 做后台轮询。 ### 首尾帧 给了 `last_image` 就走首尾帧模式:AI 在首帧和尾帧之间补出过程,终点被钉死。 不是每家都支持,**发请求前先问** `supports_last_frame(endpoint, extra)`: | 服务商 | 支持 | 方式 | |---|---|---| | 火山方舟 Seedance | ✅ | `content` 里第二个 `image_url` 带 `role: "last_frame"` | | 海螺 | ✅ | 顶层 `last_frame_image`(Hailuo-02 及以上) | | 智谱 CogVideoX | ✅ | `image_url` 传 `[首帧, 尾帧]` | | Vidu / 硅基流动 / New API 中转站 | ❌ | 宁可不给,也不发一个会被忽略或报错的字段 | ### 错误翻译 `explain_error(raw)` 把审核类拦截翻成可操作的中文(「首帧被判为真人 / 敏感内容,建议换画风或换供应商」), 其余原样截断到 300 字。提交阶段被拒和轮询阶段失败都可以过它一遍再展示。 ## 配音 ```rust use ai_profile::media::tts::{audio_format_for, synthesize, TtsParams}; let bytes = synthesize( &provider.endpoint, &provider.model, &provider.extra, // 火山语音从这里取 appid / cluster key, &TtsParams { text: "你终于来了。".into(), voice: "alex".into(), format: audio_format_for(&provider.model).into(), ..Default::default() }, ) .await?; ``` | 函数 | 用途 | |---|---| | `audio_format_for(model)` | glm-tts 只出 wav,其余 mp3 —— 合成格式、落盘扩展名、播放 mime 三者要对齐 | | `voice_catalog(model)` | 按模型给出可选音色(火山 / CosyVoice / glm-tts 各一套) | | `parse_volc_extra(extra)` | 从 extra 解析火山语音的 `appid` 与 `cluster`(缺省 `volcano_tts`) | ::: danger 火山方舟没有语音合成 配音填了火山**方舟**的地址(`ark.cn-beijing.volces.com`)会被直接拦下,不会发出请求。 语音合成在「火山语音」(`openspeech.bytedance.com`),是另一条产品线、另一个密钥。 ::: ## 代理与证书 每个 provider 都会自己建 HTTP 客户端。要走代理时传一个**底座**进来: ```rust use ai_profile::media::MediaHttp; let http = MediaHttp::from_fn(move || { let b = ai_profile::reqwest::Client::builder(); match ai_profile::reqwest::Proxy::all(&proxy_url) { Ok(p) => b.proxy(p), Err(_) => b, } }); let img = AnyImageProvider::from_config_with(image_cfg, &http); let video = AnyVideoProvider::from_config_with(video_cfg, &extra, &http); let audio = synthesize_with(endpoint, model, extra, key, ¶ms, &http).await?; ``` 底座由你配,**超时策略由本库在其后施加,覆盖不掉**:出图用「读超时」而不是整体超时 —— 整体超时会在算图慢时误杀请求,而中转站此时已经算完并照常计费,用户得到「有扣费却没图」。 ## 错误 所有调用返回 `MediaError`,只有两类: | 变体 | 含义 | 界面动作 | |---|---|---| | `InvalidInput` | 入参或配置不对(文本为空、缺 App ID、端点填错产品线) | 让用户改配置,重试没用 | | `Failed` | 其余一切(网络、上游拒绝、解析失败),消息已是可读中文 | 展示并允许重试 | `MediaError::message()` 取不带前缀的原文。 ## 相关章节 * [安装与 feature](/guide/installation) —— 开哪些 feature 得到哪些调用 * [定制服务商目录](/api/catalog) —— 私有预置与 `default_extra` * [服务商清单](/reference/providers) —— 生图 / 视频 / 配音的内置预置 --- --- url: https://ai-profile.ruoyi.plus/reference/providers.md --- # 服务商清单 ::: tip 这份清单由代码生成 数据源是 crate 里的 `preset/*.rs`,经 `cargo xtask gen-docs` 生成, 再同步到这里。仓库内有守卫测试 `providers_md_in_sync` 保证它与代码一致。 要新增或修正一家,见[加一家服务商](/reference/add-provider)。 ::: 当前共 **31** 家服务商、**42** 条预置配置。 > `base_url` 一律是**服务商文档里的原文**(含版本段、不含端点后缀)。 > OpenAI 兼容一侧原样使用、不做任何推断 —— 所以各家的版本段不统一(多数 `/v1`、 > 智谱 `/v4`、Gemini 的 `/v1beta/openai` 还不在末尾)也不影响。 > Anthropic 协议例外:末段不是版本号时自动补 `/v1`(预置里仍然写全)。 ## 按服务商 同一家的多种能力**共用一个密钥** —— 配一次就能全部启用。 | 服务商 | 能力 | Host | 类型 | |---|---|---|---| | Anthropic 官方 | 对话 | `(自定义)` | 云端 | | Claude Code 客户端(自定义接口地址) | 对话 | `(自定义)` | 自定义端点 | | Codex 客户端(自定义接口地址) | 对话 | `(自定义)` | 自定义端点 | | DeepSeek | 对话 | `api.deepseek.com` | 云端 | | 智谱 GLM | 对话 / 视频 | `open.bigmodel.cn` | 云端 | | 通义千问(阿里云百炼) | 对话 / 生图 / 视频 | `dashscope.aliyuncs.com` | 云端 | | 月之暗面 Kimi | 对话 | `api.moonshot.cn` | 云端 | | 硅基流动 SiliconFlow | 对话 / 生图 / 视频 / 配音 | `api.siliconflow.cn` | 云端 | | 火山方舟(豆包) | 对话 / 生图 / 视频 | `ark.cn-beijing.volces.com` | 云端 | | 腾讯 TokenHub | 对话 | `tokenhub.tencentmaas.com` | 云端 | | MiniMax(稀宇科技) | 对话 | `api.minimax.chat` | 自定义端点 | | 百度千帆 | 对话 | `qianfan.baidubce.com` | 自定义端点 | | 阶跃星辰 | 对话 | `api.stepfun.com` | 自定义端点 | | 百川智能 | 对话 | `api.baichuan-ai.com` | 自定义端点 | | 小米 MiMo | 对话 | `api.xiaomimimo.com` | 自定义端点 | | OpenAI 官方 | 对话 / 生图 / 配音 | `api.openai.com` | 云端 | | OpenRouter | 对话 | `openrouter.ai` | 云端 | | Google Gemini(OpenAI 兼容层) | 对话 | `generativelanguage.googleapis.com` | 云端 | | Groq | 对话 | `api.groq.com` | 云端 | | xAI Grok | 对话 | `api.x.ai` | 云端 | | Together AI | 对话 | `api.together.xyz` | 自定义端点 | | Ollama(本地) | 对话 | `localhost:11434` | 本地,需先启动服务 | | LM Studio(本地) | 对话 | `localhost:1234` | 本地,需先启动服务 | | vLLM / 自建推理服务 | 对话 | `localhost:8000` | 本地,需先启动服务 | | 其它 OpenAI 兼容(自定义接口地址) | 对话 | `(自定义)` | 自定义端点 | | 自定义生图端点 | 生图 | `(自定义)` | 自定义端点 | | 海螺 MiniMax 视频 | 视频 | `api.minimaxi.com` | 云端 | | 302.AI 视频·海螺 | 视频 | `api.302.ai` | 云端 | | 自定义视频端点 | 视频 | `(自定义)` | 自定义端点 | | 字节豆包 配音(火山语音) | 配音 | `openspeech.bytedance.com` | 云端 | | 自定义配音端点 | 配音 | `(自定义)` | 自定义端点 | ## 对话 | 预置 key | 名称 | Base URL | 默认模型 | 协议 | 核实于 | |---|---|---|---|---|---| | `anthropic_official` | Anthropic 官方 | `—` | `claude-opus-5-5` | Anthropic | 未核实 | | `claude_code` | Claude Code 客户端(自定义接口地址) | `—` | `claude-opus-5` | Anthropic | 2026-09-22 | | `codex` | Codex 客户端(自定义接口地址) | `—` | `gpt-5.6-terra` | OpenAI 兼容 | 未核实 | | `deepseek` | DeepSeek | `https://api.deepseek.com/v1` | `deepseek-flash` | OpenAI 兼容 | 2026-09-17 | | `zhipu` | 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-5.3` | OpenAI 兼容 | 未核实 | | `qwen` | 通义千问(阿里云百炼) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-plus` | OpenAI 兼容 | 未核实 | | `moonshot` | 月之暗面 Kimi | `https://api.moonshot.cn/v1` | `kimi-k3` | OpenAI 兼容 | 未核实 | | `siliconflow` | 硅基流动 SiliconFlow | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3` | OpenAI 兼容 | 未核实 | | `volcengine_ark` | 火山方舟(豆包) | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-seed-1-6-251015` | OpenAI 兼容 | 未核实 | | `tencent_tokenhub` | 腾讯 TokenHub | `https://tokenhub.tencentmaas.com/v1` | `hy3-preview` | OpenAI 兼容 | 未核实 | | `minimax` | MiniMax(稀宇科技) | `https://api.minimax.chat/v1` | `MiniMax-M1` | OpenAI 兼容 | 未核实 | | `qianfan` | 百度千帆 | `https://qianfan.baidubce.com/v2` | `ernie-4.5-turbo-128k` | OpenAI 兼容 | 未核实 | | `stepfun` | 阶跃星辰 | `https://api.stepfun.com/v1` | `step-1-flash` | OpenAI 兼容 | 未核实 | | `baichuan` | 百川智能 | `https://api.baichuan-ai.com/v1` | `Baichuan4-Turbo` | OpenAI 兼容 | 未核实 | | `mimo` | 小米 MiMo | `https://api.xiaomimimo.com/v1` | `mimo-v2-flash` | OpenAI 兼容 | 未核实 | | `openai_official` | OpenAI 官方 | `https://api.openai.com/v1` | `gpt-5.6-terra` | OpenAI 兼容 | 未核实 | | `openrouter` | OpenRouter | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-5` | OpenAI 兼容 | 未核实 | | `gemini` | Google Gemini(OpenAI 兼容层) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-3.8-flash` | OpenAI 兼容 | 未核实 | | `groq` | Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | OpenAI 兼容 | 未核实 | | `xai` | xAI Grok | `https://api.x.ai/v1` | `grok-4.5` | OpenAI 兼容 | 未核实 | | `together` | Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | OpenAI 兼容 | 未核实 | | `ollama` | Ollama(本地) | `http://localhost:11434/v1` | `qwen3:8b` | OpenAI 兼容 | 未核实 | | `lmstudio` | LM Studio(本地) | `http://localhost:1234/v1` | `—` | OpenAI 兼容 | 未核实 | | `vllm` | vLLM / 自建推理服务 | `http://localhost:8000/v1` | `—` | OpenAI 兼容 | 未核实 | | `openai_compatible_custom` | 其它 OpenAI 兼容(自定义接口地址) | `—` | `—` | OpenAI 兼容 | 未核实 | ## 生图 | 预置 key | 名称 | Base URL | 默认模型 | 协议 | 核实于 | |---|---|---|---|---|---| | `seedream_image` | 即梦 Seedream(火山方舟) | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-seedream-4-0-250828` | 按地址识别 | 未核实 | | `wan_image` | 通义万相(阿里百炼) | `https://dashscope.aliyuncs.com/api/v1` | `wan2.2-t2i-flash` | 按地址识别 | 未核实 | | `siliconflow_image` | 硅基流动 生图 | `https://api.siliconflow.cn/v1` | `Kwai-Kolors/Kolors` | 按地址识别 | 未核实 | | `openai_image` | OpenAI 生图 | `https://api.openai.com/v1` | `gpt-image-1` | 按地址识别 | 未核实 | | `custom_image` | 自定义生图端点 | `—` | `—` | 按地址识别 | 未核实 | ## 视频 | 预置 key | 名称 | Base URL | 默认模型 | 协议 | 核实于 | |---|---|---|---|---|---| | `seedance` | 即梦 Seedance(火山方舟) | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-seedance-1-0-pro-250528` | 按地址识别 | 未核实 | | `minimax_video` | 海螺 MiniMax 视频 | `https://api.minimaxi.com/v1` | `MiniMax-Hailuo-02` | 按地址识别 | 未核实 | | `vidu_video` | Vidu 视频(阿里百炼) | `https://dashscope.aliyuncs.com/api/v1` | `vidu/viduq3-turbo_img2video` | 按地址识别 | 未核实 | | `siliconflow_video` | 硅基流动 视频 Wan | `https://api.siliconflow.cn/v1` | `Wan-AI/Wan2.2-I2V-A14B` | 按地址识别 | 未核实 | | `zhipu_video` | 智谱 CogVideoX | `https://open.bigmodel.cn/api/paas/v4` | `cogvideox-flash` | 按地址识别 | 未核实 | | `ai302_minimax_video` | 302.AI 视频·海螺 | `https://api.302.ai/minimaxi/v1` | `MiniMax-Hailuo-02` | 按地址识别 | 未核实 | | `ai302_zhipu_video` | 302.AI 视频·智谱 | `https://api.302.ai/zhipu/api/paas/v4` | `cogvideox-flash` | 按地址识别 | 未核实 | | `custom_video` | 自定义视频端点 | `—` | `—` | 按地址识别 | 未核实 | ## 配音 | 预置 key | 名称 | Base URL | 默认模型 | 协议 | 核实于 | |---|---|---|---|---|---| | `volc_tts` | 字节豆包 配音(火山语音) | `https://openspeech.bytedance.com/api/v1/tts` | `volcano_tts` | 按地址识别 | 未核实 | | `siliconflow_tts` | 硅基流动 配音 CosyVoice | `https://api.siliconflow.cn/v1` | `FunAudioLLM/CosyVoice2-0.5B` | 按地址识别 | 未核实 | | `openai_tts` | OpenAI 配音 | `https://api.openai.com/v1` | `tts-1` | 按地址识别 | 未核实 | | `custom_tts` | 自定义配音端点 | `—` | `—` | 按地址识别 | 未核实 | ## 加一家服务商 见 [`CLAUDE.md`](https://github.com/bkywksj/ai-profile/blob/master/CLAUDE.md) 的「加一家 provider 的完整流程」。要点: 1. `base_url` 照抄文档原文,含版本段 2. 默认 `model` 选**够用档**而非最强档 3. `models` 只放核对过的 id,并填 `verified_at` 4. 同一厂商复用同一个 `vendor_id` 5. `cargo test` 守卫测试必须全绿 6. `cargo xtask gen-docs` 重新生成本文件 --- --- url: https://ai-profile.ruoyi.plus/reference/errors.md --- # 错误码对照 `VerifyError` 的每个变体都对应**一个具体的界面动作**。这是结构化错误的全部意义 —— `Err(String)` 只能让你显示一行红字,用户读完仍然不知道下一步该做什么。 ## 速查表 | `code` | Rust 变体 | 触发 | 界面应当给的动作 | |---|---|---|---| | `auth_failed` | `AuthFailed { detail }` | 401 / 403 | 把错误挂到**密钥输入框**上;若该预置有 `apply_url`,给「去申请密钥」 | | `not_found` | `NotFound { requested_url, suggested_url }` | 404 | `suggested_url` 非空 → **「一键改用」按钮**;为空 → 显示实际请求的地址 | | `unreachable` | `Unreachable { proxy_hint }` | 超时 / DNS / TLS | **「重试」**;`proxy_hint` 为真时提示去配代理 | | `model_not_found` | `ModelNotFound { available }` | 端点不认这个模型 | 把 `available` **展开成下拉**让用户改选 | | `protocol_mismatch` | `ProtocolMismatch { expect }` | 密钥只接受另一种协议 | 提示切到对应的服务商模板 | | `missing_extra_field` | `MissingExtraField { key }` | 专有字段没填 | **定位到那个输入框**并标红 | | `malformed` | `Malformed { detail }` | 端点返回了预期外内容 | 兜底:显示摘要 | ## 唯一「改配置没用」的那个 ```rust if e.is_actionable() { // 改配置能解决 —— 光标定位到对应输入框 } else { // 只有 Unreachable 属于这类 —— 给「重试」 } ``` 网络不通时让用户去改配置是误导 —— 他会开始怀疑地址、怀疑密钥, 而实际上什么都没错。这个区分值得单独一个方法。 ## 逐个说明 ### auth\_failed ```json { "code": "auth_failed", "detail": "Invalid API key provided" } ``` `detail` 是端点返回的原始说明摘要(截断 300 字符)。**不含密钥** —— 可以安全地写进日志。 界面上把它挂到密钥输入框,而不是弹一个全局提示 —— 用户需要知道是**哪个字段**有问题。 ::: tip probe 里 401 反而是成功信号 本库的 `cargo xtask probe` 不带密钥去探各家端点,此时 **401/403 恰恰说明地址是对的**,404 才是真问题。这个思路在你写健康检查时也用得上。 ::: ### not\_found ```json { "code": "not_found", "requested_url": "https://api.deepseek.com/models", "suggested_url": "https://api.deepseek.com/v1" } ``` `requested_url` **直接展示给用户** —— 省去他猜"到底打了哪个地址"。 这是排查地址问题时最有用的一条信息,而多数客户端不给。 `suggested_url` 非空时给「一键改用」按钮。它只在**确实看不到版本段**时才有值: | base\_url | suggested\_url | |---|---| | `https://api.deepseek.com` | `https://api.deepseek.com/v1` | | `https://api.deepseek.com/v1` | `null`(已有版本段) | | `https://open.bigmodel.cn/api/paas/v4` | `null`(智谱是 v4) | | `https://generativelanguage.googleapis.com/v1beta/openai` | `null` | 已经有版本段却 404,说明是别的问题 —— 这时候给建议会把用户引向另一个错误答案。 ### unreachable ```json { "code": "unreachable", "proxy_hint": true } ``` `proxy_hint` 为真表示这个 host 在国内通常需要代理 (`api.openai.com`、`api.anthropic.com`、`generativelanguage.googleapis.com`、 `openrouter.ai`、`api.groq.com`、`api.x.ai`)。 此时提示「国内访问该站点通常需要代理」比单说「连接失败」有用得多 —— 后者会让用户去反复检查自己的密钥。 ### model\_not\_found ```json { "code": "model_not_found", "available": ["deepseek-flash", "deepseek-v4-pro"] } ``` `available` 是端点返回的**真实清单**。正确做法是直接填进模型下拉: ```typescript case "model_not_found": setModelOptions(err.available); message.warning("端点不认识该模型,已为你载入可用清单"); ``` 让用户自己去翻文档找模型名是最差的处理 —— 清单明明就在手上。 ### protocol\_mismatch ```json { "code": "protocol_mismatch", "expect": "anthropic" } ``` 典型场景:中转站的密钥限定 `/v1/messages`,而用户选了 OpenAI 兼容的模板。 提示他切到「Claude Code 客户端」那一档即可。 ### missing\_extra\_field ```json { "code": "missing_extra_field", "key": "appid" } ``` ::: tip 这个错误最好永远不要出现 用 `check_required_fields` 在**发起验证之前**就判出来,直接把按钮置灰。 禁用态优于「点了才报错」—— 用户不会浪费一次往返,也不会怀疑是网络问题。 这个变体是第二道闸门(前端可能被绕过,也可能有别的调用方)。 ::: ### malformed 兜底变体。端点返回了非 JSON、或结构完全对不上。 `detail` **不含响应体原文** —— 避免把可能夹带密钥的内容带进日志。 ## 前端分支的注意事项 ```typescript switch (err.code) { // …各分支 default: // 🔴 必须有 message.error("验证失败,请检查配置"); } ``` ::: danger default 分支不能省 `VerifyError` 带 `#[non_exhaustive]` —— 本库新增错误变体只算 **minor** 版本。 没有 `default` 的话,新变体会让界面什么都不显示。 「点了没反应」是最难排查的一类故障,用户也最容易认为是软件坏了。 ::: 同理,Rust 侧的 `match` 也必须有 `_` 分支。 ## 与 ParseError 的分工 | | 回答的问题 | 阶段 | |---|---|---| | `ParseError` | 这段文本**是不是**一条配置 | 粘贴导入时 | | `VerifyError` | 这条配置**对不对** | 点测试连接时 | 界面表现应当不同:前者是"你粘错东西了",后者是"配置本身有问题"。 详见 [ai.profile 协议](/api/protocol#错误)。 ## 相关 * [连通性验证](/api/verify) —— 产生这些错误的 API * [前端对接](/guide/frontend) —— 完整的分支处理示例 --- --- url: https://ai-profile.ruoyi.plus/reference/changelog.md --- # 更新日志 面向使用者的版本说明:每个版本带来了什么、升级时要不要改代码。 开发过程的完整记录见仓库里的 [CHANGELOG.md](https://github.com/bkywksj/ai-profile/blob/master/CHANGELOG.md)。 ## 0.1.1 · 2026-09-24 **文档修复,无代码变更。** 升级无需改动。 * docs.rs 改为按全部 feature 构建。0.1.0 在 docs.rs 上只有默认的 `chat`, `client`(验证)与 `media`(生图 / 视频 / 配音)两大块在文档里看不到 ## 0.1.0 · 2026-09-24 首个 crates.io 版本。发布前已被五个应用按提交号接入并跑通(Sigil、Reeve、知识库、一站通、StoryLoom)。 ### 能力 | 模块 | 内容 | |---|---| | 预置 | 对话 25 家 + 生图 / 视频 / 配音 17 条,按厂商聚合成目录 | | 定制目录 | `PresetCatalog`:应用自己增删改筛、加私有服务商 | | `ai.profile` | 跨应用配置交换,单条与多条打包 | | 端点 | 拼接与模型清单清洗;OpenAI 兼容原样使用,**Anthropic 自动补 `/v1`** | | 验证 | 零成本「获取模型」,六个结构化错误各对应一个界面动作 | | 限额 | 窗口与输出上限四层取值、历史裁剪、上下文超长识别 | | 多模态调用 | 生图 2 套、视频 6 套、配音 2 套协议(`client` + 对应 feature) | ### 发布前值得知道的几个行为 * **Anthropic 协议的地址填不填 `/v1` 都能用**:末段不是版本号时自动补 `/v1`,末尾写 `#` 表示别替我补。 从 Claude Code 等工具粘来的「只填主机名」的配置因此能直接用 * **多模态 HTTP 客户端创建失败时直接报错**,不会悄悄退回一个丢了代理与超时的默认客户端 * 最低 Rust 版本 **1.88** ### 从 git 依赖切过来 发布前按提交号接入的项目,把依赖改成版本号即可: ```toml # 之前 ai-profile = { git = "https://github.com/bkywksj/ai-profile", rev = "…", features = ["chat", "client"] } # 之后 ai-profile = { version = "0.1", features = ["chat", "client"] } ``` 如果你的应用已经发布、并且用自己的旧规则给存量 Anthropic 地址补过 `/v1`,不受影响(带 `/v1` 的地址原样使用); 旧规则对带路径的地址**直接拼**的,要先读[已发布应用的接入迁移](/guide/migration)。 --- --- url: https://ai-profile.ruoyi.plus/reference/versioning.md --- # 版本策略 本库遵循[语义化版本](https://semver.org/lang/zh-CN/)。 下游是多个独立发版的桌面应用 —— **改一个 `pub` 字段就可能 break 它们全部**。 所以每次改动前先对照本表定版本位。 ## 版本位判定 | 改动 | 版本位 | 说明 | |---|---|---| | 改模型 id / 加 provider / 改 base\_url | **patch** | 纯数据更新,不动 API 形状 | | 加 `Kind` 枚举值 | **minor** | `#[non_exhaustive]` 保证下游 `match` 不 break | | 给 struct 加字段 | **minor** | 同上 | | 加公开函数 / 方法 | **minor** | | | 改 / 删 struct 已有字段 | **major** | 破坏性 | | 改函数签名 | **major** | | | 改 `preset.key` | **major** | 🔴 见下 | | 改 `ai.profile` 的**规范**字段名 | **major** | 见下 | | reqwest 大版本升级 | **major** | 🔴 见下 | ## 三个特别危险的改动 ### 1. preset.key 是存量配置的锚 用户已保存的配置靠 `key` 找回对应的预置模板(`infer_preset_key` 的返回值就是它)。 改名会让所有老配置掉进「自定义端点」—— 用户看到的是 **"我配好的服务商突然不认识了"**,而且数据看起来还在,只是不被识别了。 **要改名时**:保留旧 key 作为别名,而不是直接替换。 ::: warning 别把 key 当展示文本 它不是给人看的,展示用 `label`。改 `label` 是 patch,改 `key` 是 major。 ::: ### 2. ai.profile 的字段名只增不改 协议是跨应用契约,且**别人的工具也可能在生成它**: | 改动 | 版本位 | |---|---| | 解析端加别名(宽进) | **patch** | | 加可选字段 | **minor** | | 生成端改规范拼写 | **major** —— 且应当有极强的理由 | | 删字段 / 改字段语义 | **major** | 版本号 `v` 的兼容规则:**只拒绝更高版本**。低版本能被高版本实现读懂 (字段只增不改),拒绝低版本会把老软件分享的配置挡在外面。 ### 3. reqwest 的大版本在公开 API 里 `Verifier::from_builder` 收 `reqwest::ClientBuilder`,`lib.rs` 重导出了 `reqwest`。 后果是: | 改动 | 版本位 | |---|---| | reqwest `0.12.x` → `0.12.y` | patch | | reqwest `0.12` → `0.13` | **major** 🔴 | 这是**刻意付的代价**。不重导出的话,下游用自己依赖树里的 reqwest 建 builder, 版本一旦不同就编译失败,报错还是 `expected ClientBuilder, found ClientBuilder` 这种看不懂的形式。 宁可把耦合写进版本号,也不要让下游撞上那种错误。 ::: details 为什么不做一个 ProxyConfig 中间层 考虑过,否决了:代理配置各家差异极大 —— sigil 用的是 `reqwest::Proxy::custom(闭包)` 按 URL 动态路由,任何简化的中间层都覆盖不了, 最后还是要开一个 builder 级的逃生口。既然逃生口必然存在,不如直接让它是主路径。 ::: ## #\[non\_exhaustive] 的用法 所有公开 struct / enum 一律加 —— 这是把「加字段」从 major 降到 minor 的唯一办法。 **第一版就要加**,事后补本身就是破坏性变更。 ### 🔴 但入参类型必须同时提供 builder `#[non_exhaustive]` 让外部 crate **不能用字面量构造**该类型(E0639)。 | 类型定位 | 例子 | 做法 | |---|---|---| | **出参**(下游只读) | `ProviderPreset`、`Vendor`、`VerifyOk` | 加 `non_exhaustive` 就行 | | **入参**(下游要构造) | `ServiceConfig` | **必须配完整的链式 builder** | 否则加了 `non_exhaustive` 等于让它无法被使用。 这条是实测踩出来的:`ServiceConfig` 最初只有 `new()` 没有 `with_*()`, xtask 作为外部 crate 编译直接报 E0639。 ::: danger 单元测试抓不到这类问题 它们在 crate 内部,不受 `non_exhaustive` 限制 —— 写得再多也照样全绿。 **`tests/` 下的集成测试是唯一防线**,仓库里有两条专门守这个。 ::: ## 发版前检查 ```bash cargo test -p ai-profile # 默认 feature cargo test -p ai-profile --no-default-features --features chat cargo test -p ai-profile --features client cargo test --workspace --all-features cargo clippy --workspace --all-targets --all-features -- -D warnings cargo fmt --all --check cargo xtask gen-docs # 确认 providers.md 无变化 ``` ::: warning cargo test --workspace 不能代替前三条 xtask 依赖 `client` feature,workspace 级命令会触发 **feature unification**, 让 `ai-profile` 永远带上 client —— 「不带 client 能否编译」那条就永远测不到。 这也是实测踩出来的:开不开 `--features client` 跑出来都是同样的测试数, 看起来一切正常,实际上有一整个形态从没被验证过。 ::: `cargo xtask probe`(探活各家端点)是**人工触发的季度体检**, 不进发版流程,更不能进 CI —— 每次 PR 都打人家的接口既是滥用, 也会让 CI 随网络波动随机红。 ## 当前状态 已发布到 [crates.io](https://crates.io/crates/ai-profile),当前 `0.1.1`(2026-09-24)。 发布前五个应用(Sigil、Reeve、知识库、一站通、StoryLoom)已按提交号接入跑通,API 形状经过真实使用检验; 发布后下游统一改按版本号 `0.1` 引用。 crates.io 的版本**永久不可撤回**(只能 yank,不能删除或覆盖),所以发版前的检查清单一条都不能省。 每个版本改了什么见[更新日志](/reference/changelog),接入方式见[安装与 feature](/guide/installation)。 --- --- url: https://ai-profile.ruoyi.plus/reference/add-provider.md --- # 加一家服务商 服务商清单是这个库变动最频繁的部分,也是最欢迎 PR 的部分。 加一家只改一个数组字面量,**所有下游应用同时生效**。 ## 先判断:该不该进本库 「所有下游同时生效」是双刃剑 —— 进了本库,**每个**应用的下拉里都会出现它。 | 服务商 | 去哪 | |---|---| | 公开可注册的官方服务、通用聚合平台 | ✅ 本库,按下面的流程提 PR | | 只有某个应用在用的合作渠道、内部网关 | ❌ 那个应用用[定制服务商目录](/api/catalog)自己加 | 拿不准就问一句:换一个毫不相关的应用接入,它的用户会想在下拉里看到这一家吗? ## 流程 ### 1. 加一条 ProviderPreset 在 `crates/ai-profile/src/preset/.rs`(`chat` / `image` / `video` / `tts`)里按分组插入。 🔴 **数组顺序即呈现顺序,同一分组必须连续** —— 有守卫测试盯着。 ```rust ProviderPreset { key: "acme", vendor_id: "acme", kind: Kind::Chat, group_key: GROUP_CHINA.0, group_label: GROUP_CHINA.1, label_key: "providerTemplate.acme.label", label: "Acme AI", hint_key: None, hint: None, base_url: Some("https://api.acme.com/v1"), model: "acme-medium", models: &[ ModelOption::plain("acme-large"), ModelOption::plain("acme-medium"), ], protocol: Protocol::OpenAiCompatible, match_hosts: &["api.acme.com"], extra_fields: NO_EXTRA, default_extra: &[], apply_url: Some("https://console.acme.com/keys"), is_local: false, verified_at: None, }, ``` ### 2. base\_url 照抄文档原文 **含版本段、不含端点后缀。** | ✅ | ❌ | |---|---| | `https://api.acme.com/v1` | `https://api.acme.com`(缺版本段) | | `https://open.bigmodel.cn/api/paas/v4` | `https://api.acme.com/v1/chat/completions`(带了端点后缀) | OpenAI 兼容一侧不做任何版本段推断 —— 你写什么,用户的请求就打到哪里。 Anthropic 协议例外(末段不是版本号时自动补 `/v1`),但预置里照样写全,守卫测试要求每条都看得到版本段。 ### 3. 默认 model 选「够用档」 ```rust model: "acme-medium", // ✅ 够用档 models: &[ ModelOption::plain("acme-large"), // 最强档留在清单里随时可选 ModelOption::plain("acme-medium"), ], ``` ::: warning 别把旗舰塞进默认值 用户点开预置是奔着"能用"来的,不是奔着"最贵"。默认选旗舰等于替他做了一个 他没同意的花钱决定。 ::: 守卫测试 `default_model_is_in_its_own_list` 会检查默认值在不在自己的清单里 —— 不一致的话用户打开表单会看到一个下拉里选不中的值,以为是自己配错了。 ### 4. verified\_at:没调通就留 None | 情况 | 填什么 | |---|---| | 拿真实密钥实际调通过 | `Some("2026-09-22")` | | 照官方文档抄的,没验过 | `None` | ::: danger 不要为了"好看"填上日期 这个字段是给**后来人**判断「这条数据该不该信」用的。 照抄文档填个日期,等于把一条未验证的数据伪装成已验证。 `cargo xtask probe` 能验地址可达性,但**验不了 model id 对不对**(那要真实密钥), 所以 probe 通过不构成填 `verified_at` 的理由。 ::: 文档生成器会把 `None` 显示成「未核实」而不是留白 —— 留白会让读者以为"没这个概念",标出来才知道该自己验一下。 ### 5. vendor\_id:同一家用同一个 ```rust // 硅基流动的四种能力共用一个 vendor_id vendor_id: "siliconflow", ``` 这是「同密钥多能力」的依据。守卫测试 `vendor_ids_consistent` 要求 **同 `vendor_id` 的 base\_url host 必须一致** —— 否则服务商目录会把两家并成一张卡。 新加的能力如果与已有预置是同一家,务必复用已有的 `vendor_id`。 ### 6. 跑测试 ```bash cargo test -p ai-profile cargo test -p ai-profile --features client ``` 九个守卫测试必须全绿。它们防的是: | 测试 | 防什么 | |---|---| | `preset_groups_are_contiguous` | 同组不连续会切出重复的分组标题 | | `preset_base_urls_are_well_formed` | base\_url 漏版本段 = 下游 404 | | `preset_keys_are_unique` | key 重复会让回填逻辑静默错乱 | | `vendor_ids_consistent` | 同 vendor\_id 的 host 必须一致 | | `default_model_is_in_its_own_list` | 默认值在下拉里选不中 | | `join_api_path_never_infers_version_segment` | 防止有人把 OpenAI 兼容一侧的版本段推断加回来 | | `protocol_roundtrip` | ai.profile 解析→生成→解析 不丢字段 | | `providers_md_in_sync` | 防止文档变成又一份会漂移的副本 | | `service_config_builder_is_usable_from_outside` | `non_exhaustive` 入参类型缺 builder 时下游报 E0639 | ### 7. 重新生成文档 ```bash cargo xtask gen-docs ``` `docs/providers.md` 是**生成物,不要手改** —— 手改会让它变成第二份数据源, 而这个库存在的全部理由正是消掉重复数据源。 ### 8. 版本位 加一家 provider 是 **patch** —— 纯数据更新,不动 API 形状。 详见[版本策略](/reference/versioning)。 ## 探活现有端点 ```bash cargo xtask probe ``` 不带密钥打所有非本地预置的地址,验**地址是否还有效**。 25 家并发探测,实测几秒跑完。 ```text ✅ deepseek 地址可达(未带密钥,返回鉴权失败属正常) ✅ openrouter 937ms 433 个模型 ❌ gemini 端点不存在:https://generativelanguage.googleapis.com/v1beta/openai/models ``` 判读规则: * **401 / 403 = 地址对了** —— 没带密钥,鉴权失败恰恰说明端点存在 * **404 = 真问题** —— 地址失效了,需要核对服务商文档 ::: warning probe 是人工触发的季度体检,不进 CI 每次 PR 都去打各家服务商的接口既是滥用,也会让 CI 随网络波动随机红。 ::: ::: tip 404 也可能是假阳性 有些服务商(如 Google)用 404 隐藏未授权资源 —— 不带密钥访问存在的端点也会得到 404。 遇到 404 先查服务商文档,**别直接改预置**。 ::: ## 提 PR 时请说明 * 数据来源(官方文档链接) * 有没有用真实密钥验过(决定 `verified_at` 填不填) * 如果是已有厂商的新能力,说明为什么复用/不复用现有 `vendor_id` ## 相关 * [服务商清单](/reference/providers) —— 当前的全部数据 * [预置与服务商目录](/api/preset) —— 每个字段的含义 * [版本策略](/reference/versioning) —— 改动的版本位判定 --- --- url: https://ai-profile.ruoyi.plus/reference/spec.md --- # 其他语言实现 ai-profile 目前只有 Rust 实现,但它的价值大半与语言无关:预置数据是一份 JSON,地址拼接、错误判定、 模型清洗、`ai.profile` 解析是一组规则。所以我们把这两样单独发布出来 —— 别的语言**不用重抄数据、 不用凭文字猜规则**:数据直接拿,规则照用例写,跑通用例就与 Rust 版行为一致。 ::: tip 这些文件由代码生成 全部由 crate 仓库的 `cargo xtask gen-spec` 从 Rust 参考实现现场算出,仓库内有守卫测试 `spec_files_in_sync` 保证它们与代码一致。每个文件头都标着产出它的 crate 版本。 ::: ## 三种配合方式 | 你的需求 | 做法 | 工作量 | |---------|------|--------| | 只要服务商清单(地址、模型、密钥申请页) | 读 `presets.json`,[按 kind 筛](#只用数据) | 几十行 | | 要完整能力(拼地址、测试连接、粘贴导入…) | 按[实现范围](#实现范围)写函数,跑通对应用例 | 一到两天 | | 同上,但想省事 | 把用例交给 AI,[让它照着写并跑通](#用-ai-实现) | 半天 | 三种方式都建议**锁定版本**(见下),升级时有意识地换,而不是被动跟着变。 ## 下载 | 文件 | 内容 | |------|------| | presets.json | 全部服务商预置 + 按厂商聚合的目录 | | conformance/endpoint.json | 端点拼接 | | conformance/model\_filter.json | 模型清单清洗 | | conformance/models\_response.json | `/models` 响应解析(id 与限额) | | conformance/diagnose.json | 验证失败的错误判定、必填字段检查 | | conformance/ai\_profile.json | `ai.profile` 解析与生成 | | conformance/limits.json | token 限额三层合并 | ### 最新版与固定版本 | 地址 | 用途 | |------|------| | `https://ai-profile.ruoyi.plus/spec/…` | **最新版**,跟着 crate 发版变 | | `https://ai-profile.ruoyi.plus/spec/v0.1.1/…` | **固定版本**,发布后不再改动。自动下载、写进构建脚本时用这个 | | /spec/versions.json | 已有版本清单:`{"latest": "0.1.1", "versions": [...]}` | 最稳妥的做法是把整个目录复制进你的仓库(`spec/`),记下来源版本 —— 构建不依赖网络, 升级时整体替换、看哪些用例红了,红掉的就是这次规则变化的全部内容。 ## 实现范围 「必要性」按你要做的功能看:只做服务商下拉,前两行就够了。 | 功能 | 要实现的函数(Rust 名) | 用例 | 什么时候需要 | |------|----------------------|------|-------------| | 预置数据 | `presets` / `preset_by_key` / `vendors` | 无,直接读 `presets.json` | 所有场景 | | 端点拼接 | `join_api_path` / `join_chat_endpoint` / `anthropic_base_url` | `endpoint.json` | 发任何请求 | | 模型清洗 | `is_chat_model_id` / `clean_fetched_models` | `model_filter.json` | 做「获取模型」下拉 | | `/models` 解析 | `parse_model_ids` / `parse_model_limits` | `models_response.json` | 同上 | | 错误判定 | `diagnose` / `suggest_url` / `check_required_fields` | `diagnose.json` | 做「测试连接」 | | 测试连接 | `verify` | 无,按[下面的流程](#测试连接的网络层)把上面几个串起来 | 做「测试连接」 | | 导入导出 | `parse_profiles` / `to_profile` | `ai_profile.json` | 做粘贴导入、分享 | | 限额 | `TokenLimits::or` | `limits.json` | 显示上下文窗口 / 输出上限 | | 历史裁剪、生图 / 视频 / 配音调用 | `history` / `media` 模块 | **暂无** | 见[未覆盖的部分](#未覆盖的部分) | 函数名、参数名在你的语言里按惯例改(`join_api_path` → `joinApiPath`),行为一致即可。 ### 只用数据 `presets.json` 里的 `vendors` 是全部 kind 的聚合。只做对话的应用应当先按 kind 筛预置,再按 `vendorId` 聚合 —— 直接用 `vendors` 的话,里面会混着「只提供视频」的厂商和你不支持的 `presetKeys`。 ```python chat = [p for p in spec["presets"] if p["kind"] == "chat"] by_vendor = {} for p in chat: # 保持原数组顺序 = 分组顺序 by_vendor.setdefault(p["vendorId"], []).append(p) ``` 字段含义见[预置与服务商目录](/api/preset)。 ### 测试连接的网络层 `verify` 发网络请求,没法写成用例,但它只是把几个有用例的纯函数串起来。按这个顺序实现: 1. **发请求前**:`check_required_fields(presetKey, extra)`,缺字段直接返回 `missing_extra_field` —— 更好的做法是用它禁用「测试」按钮。 2. **定地址**:表单填了 `base_url` 用表单的,没填用预置的;都为空返回 `{"code": "missing_extra_field", "key": "base_url"}`。 Anthropic 协议先过 `anthropic_base_url`,然后 `url = join_api_path(base, "models")`。 3. **发 GET**,超时 20 秒(连接 10 秒),**禁止跟随重定向**: | 协议 | 请求头 | 密钥为空时 | |------|--------|-----------| | OpenAI 兼容 | `Authorization: Bearer ` | 不带这个头(本地 Ollama / vLLM 常不校验密钥) | | Anthropic | `x-api-key: ` + `anthropic-version: 2023-06-01` | 只带 `anthropic-version` | ::: danger 为什么必须禁重定向 HTTP 库跨域跳转时通常只剥 `Authorization` 这类标准头,**不剥自定义头** —— Anthropic 的 `x-api-key` 会被原样发给跳转目标。 ::: 4. **连不上 / 超时** → `{"code": "unreachable", "proxy_hint": …}`。`proxy_hint` 表示「这个域名在国内通常要代理」, Rust 版对 `api.openai.com`、`api.anthropic.com`、`generativelanguage.googleapis.com`、`openrouter.ai`、 `api.groq.com`、`api.x.ai` 置为 `true`。 5. **非 2xx** → `diagnose(status, body, url, base)`,其中 `base` 用第 2 步补过 `/v1` 的那个。 6. **2xx** → 组装成功结果: ```json { "latencyMs": 320, "models": ["…清洗后的清单"], "dropped": 2, "droppedModels": ["…被滤掉的"], "modelInList": true, "limits": { "contextWindow": 128000, "maxOutput": 8192, "source": "endpoint" }, "modelLimits": [["deepseek-flash", { "contextWindow": 128000, "maxOutput": 8192, "source": "endpoint" }]] } ``` * `models` / `dropped` / `droppedModels`:`clean_fetched_models(parse_model_ids(body))` * `modelInList`:当前填的 model 为空、或清单为空、或在清单里 → `true`。 **模型不在清单里不算失败**,界面据此提示「端点没有这个模型,要换一个吗」 * `limits`:`parse_model_limits(body)` 里当前 model 那一条,没有就是 `null` * `modelLimits`:`parse_model_limits(body)` 的全部结果,每项是 `[id, 限额]` 错误对象的字段与各自该给用户的动作见[错误码对照](/reference/errors)。 ### 未覆盖的部分 * **历史裁剪**(`history`):规则见[历史裁剪与超长重试](/api/history),目前没有用例, 请对照 Rust 源码的测试实现。这部分最容易出错的是「tool 调用与结果必须成对保留」。 * **生图 / 视频 / 配音**(`media`):本质是对各家 HTTP 协议的封装,每家请求格式不同,写成用例意义不大。 协议细节见[生图、视频与配音](/api/media)与 crate 源码 `src/media/`。 ## 文件格式 每个文件都带同样的头: ```json { "specVersion": 1, "crateVersion": "0.1.1", "title": "端点拼接", "description": "规则摘要", "generatedBy": "cargo xtask gen-spec …", "cases": [ … ] } ``` | 字段 | 含义 | |------|------| | `specVersion` | **格式**版本。只在字段改名、结构调整时加 1;加用例、改期望值不算。遇到不认识的值应当拒绝运行,别按旧格式硬读 | | `crateVersion` | 生成这批用例的 crate 版本,期望值跟着它走 | | `cases` | 用例列表(`presets.json` 没有这一项,换成 `presets` 与 `vendors`) | 每条用例: ```json { "fn": "join_api_path", "name": "可选说明", "input": { "base": "…", "path": "models" }, "expected": "…" } ``` * `fn` 对应一个要实现的函数,`input` 是参数(键名 camelCase),`expected` 是返回值。 * 返回 `Result` 的函数,`expected` 是 `{"ok": …}` 或 `{"error": …}` 二选一。 * `expected` 的键名照 Rust 版的线格式原样给出:数据结构是 camelCase,**错误对象是 snake\_case** (如 `suggested_url`)。这是已发布的格式,照抄即可,别统一成一种。 * 按 **JSON 值**比较(对象键无序),不要比字符串。`to_profile` 的输出也先解析再比。 ## 接进你的测试 以 Python + pytest 为例: ```python import json, pathlib, pytest from my_ai_profile import join_api_path, join_chat_endpoint, anthropic_base_url FNS = { "join_api_path": lambda i: join_api_path(i["base"], i["path"]), "join_chat_endpoint": lambda i: join_chat_endpoint(i["base"], i["path"]), "anthropic_base_url": lambda i: anthropic_base_url(i["base"]), } spec = json.loads(pathlib.Path("spec/conformance/endpoint.json").read_text("utf-8")) assert spec["specVersion"] == 1 @pytest.mark.parametrize("case", spec["cases"], ids=lambda c: f'{c["fn"]}:{c["input"]}') def test_endpoint(case): assert FNS[case["fn"]](case["input"]) == case["expected"] ``` TypeScript + Vitest 同理: ```ts import spec from './spec/conformance/endpoint.json' import { joinApiPath, joinChatEndpoint, anthropicBaseUrl } from '../src/endpoint' const fns: Record unknown> = { join_api_path: (i) => joinApiPath(i.base, i.path), join_chat_endpoint: (i) => joinChatEndpoint(i.base, i.path), anthropic_base_url: (i) => anthropicBaseUrl(i.base), } test.each(spec.cases)('$fn $input', (c) => { expect(fns[c.fn](c.input)).toEqual(c.expected) }) ``` **一条用例都别跳过。** 跳过的那条往往正是规则里最反直觉的地方(比如 Anthropic 补 `/v1` 而 OpenAI 兼容不补)。 ## 用 AI 实现 用例本身就是最好的需求说明:AI 照着写、跑测试、看哪条红、再改,直到全绿。把下面这段连同用例文件一起交给它: ```text 请用 <语言> 实现 ai-profile 的 <功能,如:端点拼接 + 获取模型 + 测试连接>。 规范与用例: - 实现说明:https://ai-profile.ruoyi.plus/reference/spec.md - 用例文件已放在本仓库 spec/ 目录(来自 https://ai-profile.ruoyi.plus/spec/v0.1.1/) 要求: 1. 先读实现说明的「实现范围」与「测试连接的网络层」两节,再动手 2. 为每个 conformance/*.json 写一个参数化测试,按 JSON 值比较 expected,一条都不许跳过 3. 不许修改用例文件来让测试通过;觉得某条期望值不合理就停下来告诉我 4. 预置数据运行时读 spec/presets.json,不要把服务商清单抄进代码 5. 全部用例通过后,列出你实现了哪些函数、哪些没实现(按实现说明里的表) ``` 第 3 条最要紧:AI 遇到过不去的用例,最省事的做法就是改用例。 ## 跟进更新 * 发版记录在 [更新日志](/reference/changelog),也可以在 GitHub 上 Watch [bkywksj/ai-profile](https://github.com/bkywksj/ai-profile) 的 Releases。 * 升级 = 把 `spec/` 换成新版本目录 → 跑测试 → 修红掉的。 * `specVersion` 变了说明格式有调整,先看更新日志再动手。 * 预置数据(新增服务商、模型换代)是更新最频繁的部分;只用数据的场景,跟得越勤越好。 ## 规则摘要 用例是权威,这里只帮你读懂用例在测什么。各规则的设计理由见对应的 API 页。 | 规则 | 要点 | 详见 | |------|------|------| | 端点拼接 | OpenAI 兼容地址**原样使用**,不补 `/v1`;只剥掉误填的对话端点后缀与末尾 `#`。Anthropic 协议是唯一例外:末段不是 `v<数字>` 就补 `/v1`,末尾 `#` 表示别补 | [端点与模型清单](/api/endpoint) | | 模型清洗 | 排除法:只滤掉带明确非对话特征的(向量、重排、语音、生图、OCR、审核…),**未知名称一律放行**;去空白、去重、保持顺序;全被滤光时原样返回 | [端点与模型清单](/api/endpoint) | | `/models` 解析 | `{"data":[…]}` 与裸数组都接受;限额只收录报了的模型,OpenRouter 优先取 `top_provider` | [限额](/api/limits) | | 错误判定 | 401/403 → `auth_failed`;404 → `not_found`(看不出版本段时带 `suggested_url`);其余 → `malformed`。必填专有字段缺失 → `missing_extra_field` | [连通性验证](/api/verify) | | `ai.profile` | 解析**宽进**(多种字段拼写、单条与打包统一成列表、OAuth 条目跳过计数);生成**严出**(只产出规范写法) | [ai.profile 协议](/api/protocol) | | 限额合并 | 用户 > 端点 > 预置,两两合并:高层全空时整条换成低层;否则逐字段补空,来源保留高层 | [限额](/api/limits) | `model_not_found`、`protocol_mismatch` 两个错误码已在格式里预留,但目前 Rust 版不会产生,实现时可以先不管。 ## 边界 * **不要改用例去迁就实现。** 觉得某条期望值不合理,请到 [crate 仓库](https://github.com/bkywksj/ai-profile/issues)提 issue —— 规则改在 Rust 版, 重新生成后所有语言一起跟上。 * 目前没有官方维护的其他语言实现。你写了一个,欢迎告诉我们,会列进本页。 ## 相关 * [ai.profile 协议](/api/protocol) —— 格式定义与「给其它实现者」的约定 * [版本策略](/reference/versioning) —— 什么改动会让期望值变化 * [用 AI 接入](/guide/ai-assisted) —— Rust 项目用 AI 接入本库 --- --- url: https://ai-profile.ruoyi.plus/public/ai/ai-profile-integration.md description: > 用于在本项目里接入或修改 ai-profile(AI 模型服务配置层 Rust crate):服务商预置、「获取模型」验证、ai.profile 粘贴导入与分享、token 限额与历史裁剪、生图 / 视频 / 配音调用。 触发场景: - 第一次把 ai-profile 接进项目,替换项目里自己写的服务商清单或地址拼接 - 做或修改「模型服务」设置页:服务商下拉、获取模型、测试连接、粘贴导入 - 对话请求前要裁历史、给 max_tokens 封顶,或处理「上下文超长」报错 - 升级 ai-profile 版本,或想加新模型 / 新服务商 触发词:ai-profile、模型服务、服务商预置、provider、获取模型、测试连接、ai.profile、粘贴导入、限额、上下文窗口、历史裁剪 --- # 接入 ai-profile ai-profile 是给应用用的「AI 模型服务配置层」:服务商预置、地址拼接、零成本验证、跨应用配置互通、限额。 **它不发对话请求、不存密钥** —— 那两件事留在本项目。 * 文档:(给 AI 读的全文:) * API: * 可运行示例: 动手前先读全文文档,不要凭印象写 —— 本库 2026-09 才发布,训练数据里没有它。 ## 加依赖 ```toml ai-profile = "0.1" # 只要预置与纯函数(可编到移动端) ai-profile = { version = "0.1", features = ["client"] } # + 「获取模型」验证 ai-profile = { version = "0.1", features = ["client", "image", "video", "tts"] } # + 多模态调用 ``` 最低 Rust 1.88。升级同一小版本用 `cargo update -p ai-profile`;升 `0.x` 小版本前先读更新日志。 ## 🔴 必须遵守的规则 | 规则 | 为什么 | |---|---| | **不要在本项目里再写服务商清单、模型列表、协议映射、地址拼接** —— 一律用本库的 | 本库存在的全部理由就是消掉这些副本;自己再抄一份,模型 id 一变就会和本库不一致 | | 要加模型 / 加服务商:优先向本库提 issue 或 PR;只属于本应用的私有服务商用 `PresetCatalog::new().extend(&LOCAL)` 在本项目里加 | 公共数据放公共库,私有数据留在应用 | | OpenAI 兼容地址**原样使用**,不要替用户补 `/v1` | 各家版本段不同(智谱 `/v4`、Gemini `/v1beta/openai`),补错只会 404 | | Anthropic 协议地址**也不要手动补 `/v1`** —— 用 `endpoint::join_chat_endpoint(base, "messages")`,本库会按约定补 | 用户从 Claude Code 等工具粘来的地址常常只填到主机名;手动补会变成 `/v1/v1` | | `match` 本库的公开枚举(`VerifyError`、`ParseError`、`Kind`、`Protocol`…)**必须带 `_` 分支** | 它们都是 `#[non_exhaustive]`,本库加变体只算小版本,不写 `_` 升级后编译不过 | | `ServiceConfig` 只能用 builder 构造:`ServiceConfig::new(..).with_api_key(..).with_model(..)` | 不能写结构体字面量(E0639) | | `Verifier` **建一次、存进全局状态反复用**;要代理用 `Verifier::from_builder(ai_profile::reqwest::Client::builder().proxy(..))` | 每次现建连接池永远是空的;用本库重导出的 `reqwest` 才能保证版本一致 | | 验证失败按 `VerifyError` 的变体给**界面动作**,不要只显示一行红字 | 404 带 `suggested_url` 可做「一键改用」,缺专有字段能在发请求前就禁用按钮 | | 密钥由本项目存储和加密;错误信息里本库不会带密钥,可以放心写日志 | 本库不持久化任何东西 | | 限额的「预置」来源**不要存进数据库**,每次现查;只存用户手填与端点上报的值 | 预置随本库升级而更新,存下来就冻结在旧值上 | ## 🔴 如果本项目**已经发布过**、用户手里存着配置 换成本库之前,先把存量地址修正成本库的语义:本项目原来的拼接逻辑很可能会「帮用户补 `/v1`」, 换成本库后这些地址会 404,而且用户看不出原因。做法: 1. 把旧的拼接函数原样复制进测试模块,改名 `legacy_join`,注明不要改 2. 写一个修正函数:输入旧地址,输出在本库语义下与旧规则**请求同一个地址**的新地址 3. 对照测试:对一批真实可能出现的地址、每一条会用到的路径,断言 `本库拼接(修正后) == legacy_join(原值)` 4. 修正必须幂等;只在数据库升级迁移、整库恢复、本项目旧版导出格式这些**旧来源**上跑,新建 / 编辑 / `ai.profile` 导入不修正 5. 上线前用真实用户库的**副本**跑一遍迁移,看改了哪几条 详见 。 ## 常用做法速查 | 要做的 | 用什么 | |---|---| | 服务商下拉 | `vendors(&[Kind::Chat])`,按厂商聚合;`is_local` 区分「去申请密钥」和「先启动服务」 | | 选中后预填 | `preset_by_key(key)` → `base_url`(`None` 表示让用户填)/ `model` / `models` / `apply_url` | | 获取模型 | `Verifier::verify(cfg).await` → `VerifyOk { models, dropped, model_in_list, limits, .. }` | | 粘贴导入 | `parse_profiles(text, 兜底模型)` → 列表;`skipped > 0` 要告诉用户 | | 分享 | `to_profile(name, protocol, base_url, api_key, model)` | | 对话地址 | `endpoint::join_chat_endpoint(base, "chat/completions" \| "messages")` | | 限额 | `TokenLimits` 逐字段 `or` 叠加:用户 > 端点上报 > `preset::model_limits(..)` | | 裁历史 | 消息结构实现 `history::HistoryMessage` → `history_budget` + `trim_history`;`dropped > 0` 要提示用户 | | 超长重试 | `history::is_context_overflow(status, body)` → `retry_budget(&msgs)` 再裁,最多三次 | | 生图 / 视频 / 配音 | `media::image::AnyImageProvider` / `media::video::AnyVideoProvider` / `media::tts::synthesize_with`,代理走 `media::MediaHttp::from_fn` | 每一行在示例目录里都有能编译的完整代码,照着改比从文档片段拼更可靠。 ## 完成后 * 删掉本项目里被本库取代的旧实现(不是「不再调用」,是删掉),避免以后有人又改回去 * 跑本项目的全量测试;已发布的项目再做一次真实库副本的迁移演练