use super::{presets, vendor::vendors_all};
use crate::kind::Kind;
const HEADER: &str = "<!-- 🔴 本文件由 `cargo xtask gen-docs` 生成,请勿手改。\n \
改预置请编辑 crates/ai-profile/src/preset/*.rs,再重新生成。\n \
守卫测试 `providers_md_in_sync` 会比对一致性。 -->\n";
#[doc(hidden)]
pub fn render_providers_markdown() -> String {
let mut s = String::with_capacity(8 * 1024);
s.push_str(HEADER);
s.push_str("\n# 支持的服务商\n\n");
let all = presets();
let vs = vendors_all();
s.push_str(&format!(
"当前共 **{}** 家服务商、**{}** 条预置配置。\n\n",
vs.len(),
all.len()
));
s.push_str(
"> `base_url` 一律是**服务商文档里的原文**(含版本段、不含端点后缀)。\n\
> 本 crate 原样使用它、不做任何推断 —— 所以各家的版本段不统一(多数 `/v1`、\n\
> 智谱 `/v4`、Gemini 的 `/v1beta/openai` 还不在末尾)也不影响。\n\n",
);
s.push_str("## 按服务商\n\n");
s.push_str(
"同一家的多种能力**共用一个密钥** —— 配一次就能全部启用。\n\n\
| 服务商 | 能力 | Host | 类型 |\n|---|---|---|---|\n",
);
for v in &vs {
let kinds = v
.kinds
.iter()
.map(|k| k.label())
.collect::<Vec<_>>()
.join(" / ");
let host = v.host.unwrap_or("(自定义)");
let ty = if v.is_local {
"本地,需先启动服务"
} else if v.apply_url.is_some() {
"云端"
} else {
"自定义端点"
};
s.push_str(&format!(
"| {} | {} | `{}` | {} |\n",
v.label, kinds, host, ty
));
}
for kind in ALL_KINDS {
let items: Vec<_> = all.iter().filter(|p| p.kind == *kind).collect();
if items.is_empty() {
continue;
}
s.push_str(&format!("\n## {}\n\n", kind.label()));
s.push_str("| 预置 key | 名称 | Base URL | 默认模型 | 协议 | 核实于 |\n");
s.push_str("|---|---|---|---|---|---|\n");
for p in items {
let base = p.base_url.unwrap_or("—");
let model = if p.model.is_empty() { "—" } else { p.model };
let proto = match p.protocol {
crate::kind::Protocol::Anthropic => "Anthropic",
_ => "OpenAI 兼容",
};
let verified = p.verified_at.unwrap_or("未核实");
s.push_str(&format!(
"| `{}` | {} | `{}` | `{}` | {} | {} |\n",
p.key, p.label, base, model, proto, verified
));
}
}
s.push_str("\n## 加一家服务商\n\n");
s.push_str(
"见 [`CLAUDE.md`](../CLAUDE.md) 的「加一家 provider 的完整流程」。要点:\n\n\
1. `base_url` 照抄文档原文,含版本段\n\
2. 默认 `model` 选**够用档**而非最强档\n\
3. `models` 只放核对过的 id,并填 `verified_at`\n\
4. 同一厂商复用同一个 `vendor_id`\n\
5. `cargo test` 七个守卫测试必须全绿\n\
6. `cargo xtask gen-docs` 重新生成本文件\n",
);
s
}
const ALL_KINDS: &[Kind] = &[
Kind::Chat,
#[cfg(feature = "image")]
Kind::Image,
#[cfg(feature = "video")]
Kind::Video,
#[cfg(feature = "tts")]
Kind::Tts,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(all(feature = "image", feature = "video", feature = "tts"))]
fn providers_md_in_sync() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/providers.md");
if !path.parent().is_some_and(|d| d.is_dir()) {
eprintln!("跳过:不在源码仓库内({} 不存在)", path.display());
return;
}
let on_disk = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"读不到 {}:{e}\n跑 `cargo xtask gen-docs` 生成它",
path.display()
)
});
let expected = render_providers_markdown();
let norm = |s: &str| s.replace("\r\n", "\n");
assert_eq!(
norm(&on_disk),
norm(&expected),
"docs/providers.md 与代码不一致 —— 跑 `cargo xtask gen-docs` 重新生成"
);
}
#[test]
fn rendered_doc_covers_every_preset() {
let md = render_providers_markdown();
for p in presets() {
assert!(
md.contains(&format!("`{}`", p.key)),
"生成的文档漏了预置 {}",
p.key
);
}
}
#[test]
fn unverified_presets_are_labeled() {
let md = render_providers_markdown();
let unverified = presets().iter().filter(|p| p.verified_at.is_none()).count();
if unverified > 0 {
assert!(
md.contains("未核实"),
"有 {unverified} 条未核实的预置,文档里应当标出来"
);
}
}
}