Skip to main content

relay_knowledge/model_provider/
profile.rs

1//! Owns public model profile contracts and their persisted representation.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7pub(super) const DEFAULT_PROFILE_NAME: &str = "default";
8const DEFAULT_CONNECT_TIMEOUT_SECONDS: f64 = 30.0;
9const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
10const DEFAULT_CODEAGENT_BASE_URL: &str = "https://codeagentcli.rnd.huawei.com/codeAgentPro";
11const DEFAULT_MAAS_BASE_URL: &str =
12    "http://snapengine.cida.cce.prod-szv-g.dragon.tools.huawei.com/api/v2/";
13
14pub(super) fn default_temperature() -> f64 {
15    0.7
16}
17
18pub(super) fn default_top_p() -> f64 {
19    1.0
20}
21
22pub(super) fn default_connect_timeout_seconds() -> f64 {
23    DEFAULT_CONNECT_TIMEOUT_SECONDS
24}
25
26/// Model provider family accepted by profile configuration.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ModelProviderKind {
30    #[serde(rename = "openai_compatible")]
31    OpenAiCompatible,
32    Anthropic,
33    Bigmodel,
34    Minimax,
35    Maas,
36    Codeagent,
37    Echo,
38}
39
40impl ModelProviderKind {
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::OpenAiCompatible => "openai_compatible",
44            Self::Anthropic => "anthropic",
45            Self::Bigmodel => "bigmodel",
46            Self::Minimax => "minimax",
47            Self::Maas => "maas",
48            Self::Codeagent => "codeagent",
49            Self::Echo => "echo",
50        }
51    }
52
53    pub(super) const fn default_base_url(self) -> Option<&'static str> {
54        match self {
55            Self::Anthropic => Some(DEFAULT_ANTHROPIC_BASE_URL),
56            Self::Codeagent => Some(DEFAULT_CODEAGENT_BASE_URL),
57            Self::Maas => Some(DEFAULT_MAAS_BASE_URL),
58            Self::Echo => Some("http://127.0.0.1/echo"),
59            Self::OpenAiCompatible | Self::Bigmodel | Self::Minimax => None,
60        }
61    }
62}
63
64/// Secret-bearing request header configured for a model profile.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ModelRequestHeader {
67    pub name: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub value: Option<String>,
70    #[serde(default)]
71    pub secret: bool,
72    #[serde(default)]
73    pub configured: bool,
74}
75
76impl ModelRequestHeader {
77    fn redacted(&self) -> Self {
78        Self {
79            name: self.name.clone(),
80            value: (!self.secret).then(|| self.value.clone()).flatten(),
81            secret: self.secret,
82            configured: self.configured || self.value.is_some(),
83        }
84    }
85}
86
87/// Optional model capability matrix surfaced in Settings.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ModelCapabilities {
90    #[serde(default)]
91    pub input: ModelModalityMatrix,
92    #[serde(default)]
93    pub output: ModelModalityMatrix,
94}
95
96/// Capability flags per modality.
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ModelModalityMatrix {
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub text: Option<bool>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub image: Option<bool>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub audio: Option<bool>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub video: Option<bool>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub pdf: Option<bool>,
109}
110
111/// User-editable profile payload used by the Web API.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ModelProfileSaveRequest {
114    pub provider: ModelProviderKind,
115    pub model: String,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub base_url: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub api_key: Option<String>,
120    #[serde(default)]
121    pub clear_api_key: bool,
122    #[serde(default)]
123    pub headers: Vec<ModelRequestHeader>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub ssl_verify: Option<bool>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub context_window: Option<u32>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub max_tokens: Option<u32>,
130    #[serde(default = "default_temperature")]
131    pub temperature: f64,
132    #[serde(default = "default_top_p")]
133    pub top_p: f64,
134    #[serde(default = "default_connect_timeout_seconds")]
135    pub connect_timeout_seconds: f64,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub capabilities: Option<ModelCapabilities>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub fallback_policy_id: Option<String>,
140    #[serde(default)]
141    pub fallback_priority: u32,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub catalog_provider_id: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub catalog_provider_name: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub catalog_model_name: Option<String>,
148    #[serde(default)]
149    pub is_default: bool,
150}
151
152/// Redacted profile returned by diagnostics and Web Settings.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct ModelProfileView {
155    pub name: String,
156    pub provider: ModelProviderKind,
157    pub model: String,
158    pub base_url: String,
159    pub api_key_configured: bool,
160    pub headers: Vec<ModelRequestHeader>,
161    pub ssl_verify: Option<bool>,
162    pub context_window: Option<u32>,
163    pub max_tokens: Option<u32>,
164    pub temperature: f64,
165    pub top_p: f64,
166    pub connect_timeout_seconds: f64,
167    pub capabilities: ModelCapabilities,
168    pub fallback_policy_id: Option<String>,
169    pub fallback_priority: u32,
170    pub catalog_provider_id: Option<String>,
171    pub catalog_provider_name: Option<String>,
172    pub catalog_model_name: Option<String>,
173    pub is_default: bool,
174    pub source: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub(super) struct StoredModelProfile {
179    pub(super) provider: ModelProviderKind,
180    pub(super) model: String,
181    pub(super) base_url: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub(super) api_key: Option<String>,
184    #[serde(default)]
185    pub(super) headers: Vec<ModelRequestHeader>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub(super) ssl_verify: Option<bool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub(super) context_window: Option<u32>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub(super) max_tokens: Option<u32>,
192    pub(super) temperature: f64,
193    pub(super) top_p: f64,
194    pub(super) connect_timeout_seconds: f64,
195    #[serde(default)]
196    pub(super) capabilities: ModelCapabilities,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub(super) fallback_policy_id: Option<String>,
199    pub(super) fallback_priority: u32,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub(super) catalog_provider_id: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub(super) catalog_provider_name: Option<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub(super) catalog_model_name: Option<String>,
206    #[serde(default)]
207    pub(super) is_default: bool,
208    pub(super) source: String,
209}
210
211impl StoredModelProfile {
212    pub(super) fn to_view(&self, name: &str, is_default: bool) -> ModelProfileView {
213        ModelProfileView {
214            name: name.to_owned(),
215            provider: self.provider,
216            model: self.model.clone(),
217            base_url: redacted_url(&self.base_url),
218            api_key_configured: self.api_key.is_some(),
219            headers: self
220                .headers
221                .iter()
222                .map(ModelRequestHeader::redacted)
223                .collect(),
224            ssl_verify: self.ssl_verify,
225            context_window: self.context_window,
226            max_tokens: self.max_tokens,
227            temperature: self.temperature,
228            top_p: self.top_p,
229            connect_timeout_seconds: self.connect_timeout_seconds,
230            capabilities: self.capabilities.clone(),
231            fallback_policy_id: self.fallback_policy_id.clone(),
232            fallback_priority: self.fallback_priority,
233            catalog_provider_id: self.catalog_provider_id.clone(),
234            catalog_provider_name: self.catalog_provider_name.clone(),
235            catalog_model_name: self.catalog_model_name.clone(),
236            is_default,
237            source: self.source.clone(),
238        }
239    }
240}
241
242pub(super) fn redacted_url(value: &str) -> String {
243    let Some((scheme, rest)) = value.split_once("://") else {
244        return value.to_owned();
245    };
246    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
247    let (authority, suffix) = rest.split_at(authority_end);
248    authority
249        .rsplit_once('@')
250        .map(|(_, host)| format!("{scheme}://{host}{suffix}"))
251        .unwrap_or_else(|| value.to_owned())
252}
253
254/// Redacted list response for all configured model profiles.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct ModelProfilesResponse {
257    pub loaded: bool,
258    pub default_profile: Option<String>,
259    pub profiles: Vec<ModelProfileView>,
260    pub error: Option<String>,
261}
262
263/// Small runtime summary embedded in project status.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ModelProfileRuntimeSummary {
266    pub loaded: bool,
267    pub profile_count: usize,
268    pub default_profile: Option<String>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub error: Option<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub(super) struct StoredProfileFile {
275    pub(super) default_profile: Option<String>,
276    pub(super) profiles: BTreeMap<String, StoredModelProfile>,
277}
278
279#[cfg(test)]
280#[path = "profile_tests.rs"]
281mod tests;