Skip to main content

ai_profile/
kind.rs

1//! 能力类型(kind)—— 决定一条预置属于对话 / 生图 / 视频 / 配音。
2//!
3//! # 🔴 kind 是数据,不是分支
4//!
5//! 调用方应当**遍历** `kinds` 渲染界面,而不是写 `if kind == Video`。
6//! 验收判据:新增一种 kind 时,下游前端应零改动。
7//!
8//! # 四种 kind 的执行模型根本不同
9//!
10//! | kind  | 执行模型                    | 每家的差异           |
11//! |-------|-----------------------------|----------------------|
12//! | chat  | 同步 / 流式                 | 协议二分(openai / anthropic) |
13//! | image | 同步 **或** submit+poll     | 因家而异             |
14//! | video | **必然 submit+poll**        | 轮询协议各家都不同   |
15//! | tts   | 同步,返回字节流            | 有的专有协议         |
16//!
17//! 所以 client 层**按 kind 分 trait**,不强求一个统一接口。
18
19use serde::{Deserialize, Serialize};
20
21/// 模型服务提供的能力类型。
22///
23/// `#[non_exhaustive]`:加枚举值是 minor 而非 major —— 下游 `match` 必须带 `_` 分支。
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
25#[serde(rename_all = "lowercase")]
26#[non_exhaustive]
27pub enum Kind {
28    /// 对话(大纲 / 续写 / 问答…)
29    Chat,
30    /// 文生图 / 图生图
31    #[cfg(feature = "image")]
32    Image,
33    /// 图生视频 / 文生视频(异步任务)
34    #[cfg(feature = "video")]
35    Video,
36    /// 语音合成
37    #[cfg(feature = "tts")]
38    Tts,
39}
40
41impl Kind {
42    /// i18n key(调用方用 `t()` 解析)。
43    pub const fn label_key(self) -> &'static str {
44        match self {
45            Kind::Chat => "kind.chat",
46            #[cfg(feature = "image")]
47            Kind::Image => "kind.image",
48            #[cfg(feature = "video")]
49            Kind::Video => "kind.video",
50            #[cfg(feature = "tts")]
51            Kind::Tts => "kind.tts",
52        }
53    }
54
55    /// 纯文本名 —— 给没接 i18n 的消费方(如 knowledge_base)。
56    pub const fn label(self) -> &'static str {
57        match self {
58            Kind::Chat => "对话",
59            #[cfg(feature = "image")]
60            Kind::Image => "生图",
61            #[cfg(feature = "video")]
62            Kind::Video => "视频",
63            #[cfg(feature = "tts")]
64            Kind::Tts => "配音",
65        }
66    }
67
68    /// 本 kind 是否提供「试运行」。
69    ///
70    /// 🔴 video 返回 false:单次生成约 90 秒且费用较高(约 ¥1–5),
71    /// 不该做成随手一点的按钮 —— 让用户在真实业务流程里验证。
72    pub const fn supports_dry_run(self) -> bool {
73        match self {
74            Kind::Chat => true,
75            #[cfg(feature = "image")]
76            Kind::Image => true,
77            #[cfg(feature = "video")]
78            Kind::Video => false,
79            #[cfg(feature = "tts")]
80            Kind::Tts => true,
81        }
82    }
83}
84
85/// 对话接口协议:决定走 `/v1/messages` 还是 `/v1/chat/completions`。
86///
87/// 🔴 线格式是 `"anthropic"` / `"openai_compatible"`,与 [`Protocol::as_str`] 同一套拼写。
88/// 此前靠 `rename_all = "snake_case"` 自动推出 `"open_ai_compatible"`,
89/// 下游存的是 `openai_compatible`,前端不得不写一层转换 —— 两种拼写并存,
90/// 迟早有一处直接透传而静默错配。
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[non_exhaustive]
93pub enum Protocol {
94    /// Anthropic 原生:`/v1/messages` + `x-api-key`
95    #[serde(rename = "anthropic")]
96    Anthropic,
97    /// OpenAI 兼容:`/v1/chat/completions` + `Authorization: Bearer`
98    #[serde(rename = "openai_compatible")]
99    OpenAiCompatible,
100}
101
102impl Protocol {
103    /// 规范字符串,与 serde 线格式一致。调用方持久化协议时用它。
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Protocol::Anthropic => "anthropic",
107            Protocol::OpenAiCompatible => "openai_compatible",
108        }
109    }
110
111    /// 从字符串解析;认不出返回 `None`。
112    ///
113    /// 只收规范拼写 —— 这是给「读自己存的值」用的,存进去的一定是 [`Self::as_str`]。
114    /// 解析别人家分享来的配置(`"openai"` / `"custom"` 之类)请用
115    /// [`crate::protocol::parse_profile`],那边有宽松的别名映射。
116    pub fn parse(s: &str) -> Option<Self> {
117        match s.trim() {
118            "anthropic" => Some(Protocol::Anthropic),
119            "openai_compatible" => Some(Protocol::OpenAiCompatible),
120            _ => None,
121        }
122    }
123
124    /// 用户没填 base_url 时的官方端点。
125    ///
126    /// 🔴 带版本段:[`crate::endpoint`] 原样拼接、不做推断,少一段就是 404。
127    pub const fn default_base_url(self) -> &'static str {
128        match self {
129            Protocol::Anthropic => "https://api.anthropic.com/v1",
130            Protocol::OpenAiCompatible => "https://api.openai.com/v1",
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// 🔴 video 不给试运行 —— 这条是成本约束,不是实现遗漏。
140    #[test]
141    fn video_has_no_dry_run() {
142        assert!(Kind::Chat.supports_dry_run());
143        #[cfg(feature = "video")]
144        assert!(
145            !Kind::Video.supports_dry_run(),
146            "视频单次约 90 秒且费用高,不提供试运行"
147        );
148    }
149
150    /// 🔴 serde 线格式、`as_str`、`parse` 三者必须是同一套拼写。
151    ///
152    /// 不一致的后果是静默的:前端拿 serde 的值去比对后端存的 `as_str`,永远不相等。
153    #[test]
154    fn protocol_spellings_agree() {
155        for p in [Protocol::Anthropic, Protocol::OpenAiCompatible] {
156            let wire = serde_json::to_value(p).unwrap();
157            assert_eq!(wire, p.as_str(), "serde 线格式与 as_str 不一致");
158            assert_eq!(Protocol::parse(p.as_str()), Some(p));
159            let back: Protocol = serde_json::from_value(wire).unwrap();
160            assert_eq!(back, p);
161        }
162        assert_eq!(
163            Protocol::parse("open_ai_compatible"),
164            None,
165            "旧拼写不再接受"
166        );
167    }
168
169    /// 默认端点必须带版本段 —— endpoint 模块不再替调用方补。
170    #[test]
171    fn default_base_url_has_version_segment() {
172        for p in [Protocol::Anthropic, Protocol::OpenAiCompatible] {
173            assert!(
174                crate::endpoint::ends_with_version_segment(p.default_base_url()),
175                "{} 缺版本段",
176                p.default_base_url()
177            );
178        }
179    }
180}