use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderSummary {
pub id: String,
pub base_url: String,
pub api_key_env: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_scope: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersListFilter {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersListResponse {
pub providers: Vec<LlmProviderSummary>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderUpsertInput {
pub id: String,
pub base_url: String,
#[serde(default)]
pub api_key_env: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub factory_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_secret_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_secret_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<AuthMode>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersDeleteParams {
pub provider_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersDeleteResponse {
pub removed: bool,
}
pub const LLM_PROVIDERS_PROBE_DRAFT_METHOD: &str = "nexo/admin/llm_providers/probe_draft";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeDraftInput {
pub factory_type: String,
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<AuthMode>,
#[serde(default)]
pub fields: std::collections::BTreeMap<String, String>,
}
pub const LLM_PROVIDERS_OAUTH_START_METHOD: &str = "nexo/admin/llm_providers/oauth_start";
pub const LLM_PROVIDERS_OAUTH_FINISH_METHOD: &str = "nexo/admin/llm_providers/oauth_finish";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthStartInput {
pub factory_type: String,
pub auth_mode: AuthMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthStartResponse {
pub session_id: String,
pub authorize_url: String,
pub expires_at_ms: i64,
pub flow_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub polling_interval_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthFinishInput {
pub session_id: String,
pub instance_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OAuthFinishResponse {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_email: Option<String>,
pub expires_at_ms: i64,
pub secret_id: String,
}
pub const LLM_PROVIDERS_PROBE_METHOD: &str = "nexo/admin/llm_providers/probe";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeInput {
pub provider_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderProbeResponse {
pub ok: bool,
pub status: u16,
pub latency_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_names: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub const LLM_PROVIDERS_CATALOG_METHOD: &str = "nexo/admin/llm_providers/catalog";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProviderCatalogEntry {
pub id: String,
pub default_base_url: String,
pub default_env_var: String,
pub models: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_schema: Vec<CredentialFieldDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supported_auth_modes: Vec<AuthMode>,
#[serde(default)]
pub supports_models_probe: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LlmProvidersCatalogResponse {
pub providers: Vec<LlmProviderCatalogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CredentialFieldDescriptor {
pub name: String,
pub label: String,
pub kind: FieldKind,
pub required: bool,
pub secret: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validation: Option<FieldValidation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depends_on: Option<DependsOn>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FieldKind {
Text,
Password,
Select {
options: Vec<SelectOption>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FieldValidation {
Regex {
pattern: String,
hint: String,
},
Length {
min: usize,
max: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DependsOn {
pub field: String,
pub any_of: Vec<String>,
}
impl DependsOn {
pub fn any_of(field: impl Into<String>, values: &[&str]) -> Self {
Self {
field: field.into(),
any_of: values.iter().map(|s| s.to_string()).collect(),
}
}
pub fn satisfied(&self, fields: &BTreeMap<String, String>) -> bool {
match fields.get(&self.field) {
Some(v) => self.any_of.iter().any(|allowed| allowed == v),
None => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AuthMode {
#[default]
#[serde(rename = "api_key")]
ApiKey,
#[serde(rename = "setup_token")]
SetupToken,
#[serde(rename = "oauth_auth_code")]
OAuthAuthCode,
#[serde(rename = "oauth_device_code")]
OAuthDeviceCode,
#[serde(rename = "oauth_bundle_import")]
OAuthBundleImport,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "code")]
pub enum LlmProviderError {
#[serde(rename = "MISSING_FIELD")]
MissingField {
field: String,
},
#[serde(rename = "UNKNOWN_FIELD")]
UnknownField {
field: String,
},
#[serde(rename = "INVALID_FORMAT")]
InvalidFormat {
field: String,
hint: String,
},
#[serde(rename = "INVALID_AUTH_MODE")]
InvalidAuthMode {
factory: String,
mode: String,
},
#[serde(rename = "SESSION_EXPIRED")]
SessionExpired,
#[serde(rename = "SESSION_NOT_FOUND")]
SessionNotFound,
#[serde(rename = "OAUTH_EXCHANGE_FAILED")]
OAuthExchangeFailed {
upstream_status: u16,
message: String,
},
#[serde(rename = "PROBE_FAILED")]
ProbeFailed {
upstream_status: u16,
message: String,
},
#[serde(rename = "YAML_WRITE_FAILED")]
YamlWriteFailed {
detail: String,
},
#[serde(rename = "SECRET_WRITE_FAILED")]
SecretWriteFailed {
detail: String,
},
}
#[cfg(test)]
mod schema_tests {
use super::*;
#[test]
fn credential_field_descriptor_round_trip() {
let d = CredentialFieldDescriptor {
name: "api_key".into(),
label: "API key".into(),
kind: FieldKind::Password,
required: true,
secret: true,
default: None,
help: Some("sk-…".into()),
validation: Some(FieldValidation::Length { min: 1, max: 200 }),
depends_on: None,
};
let v = serde_json::to_value(&d).unwrap();
let back: CredentialFieldDescriptor = serde_json::from_value(v).unwrap();
assert_eq!(d, back);
}
#[test]
fn field_kind_select_serializes_with_options() {
let k = FieldKind::Select {
options: vec![
SelectOption {
value: "global".into(),
label: "Global".into(),
},
SelectOption {
value: "cn".into(),
label: "China".into(),
},
],
};
let s = serde_json::to_string(&k).unwrap();
assert!(s.contains("\"type\":\"select\""));
assert!(s.contains("\"value\":\"global\""));
let back: FieldKind = serde_json::from_str(&s).unwrap();
assert_eq!(k, back);
}
#[test]
fn auth_mode_wire_form_is_lowercase_oauth() {
for (mode, wire) in [
(AuthMode::ApiKey, "\"api_key\""),
(AuthMode::SetupToken, "\"setup_token\""),
(AuthMode::OAuthAuthCode, "\"oauth_auth_code\""),
(AuthMode::OAuthDeviceCode, "\"oauth_device_code\""),
(AuthMode::OAuthBundleImport, "\"oauth_bundle_import\""),
] {
let s = serde_json::to_string(&mode).unwrap();
assert_eq!(s, wire);
let back: AuthMode = serde_json::from_str(&s).unwrap();
assert_eq!(back, mode);
}
}
#[test]
fn llm_provider_error_round_trip_typed_data() {
let e = LlmProviderError::MissingField {
field: "group_id".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains("\"code\":\"MISSING_FIELD\""));
assert!(s.contains("\"field\":\"group_id\""));
let back: LlmProviderError = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
let e = LlmProviderError::OAuthExchangeFailed {
upstream_status: 401,
message: "invalid grant".into(),
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains("\"code\":\"OAUTH_EXCHANGE_FAILED\""));
let back: LlmProviderError = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn upsert_input_legacy_payload_round_trips_without_new_fields() {
let raw = r#"{"id":"minimax","base_url":"https://x","api_key_env":"K"}"#;
let i: LlmProviderUpsertInput = serde_json::from_str(raw).unwrap();
assert_eq!(i.id, "minimax");
assert!(i.fields.is_empty());
assert!(i.auth_mode.is_none());
let back = serde_json::to_string(&i).unwrap();
assert!(!back.contains("auth_mode"));
assert!(!back.contains("\"fields\":"));
}
#[test]
fn upsert_input_schema_payload_round_trip() {
let mut fields = BTreeMap::new();
fields.insert("api_key".into(), "sk-test".into());
fields.insert("group_id".into(), "1234567890123".into());
fields.insert("region".into(), "global".into());
let i = LlmProviderUpsertInput {
id: "minimax-cliente-a".into(),
base_url: "https://api.minimax.io/v1".into(),
factory_type: Some("minimax".into()),
auth_mode: Some(AuthMode::ApiKey),
fields,
..Default::default()
};
let s = serde_json::to_string(&i).unwrap();
assert!(s.contains("\"auth_mode\":\"api_key\""));
assert!(s.contains("\"fields\":{"));
let back: LlmProviderUpsertInput = serde_json::from_str(&s).unwrap();
assert_eq!(back.fields.len(), 3);
assert_eq!(back.auth_mode, Some(AuthMode::ApiKey));
}
#[test]
fn catalog_entry_legacy_payload_deserialises_into_82_10_u_shape() {
let raw = r#"{
"id": "minimax",
"default_base_url": "https://api.minimax.io/v1",
"default_env_var": "MINIMAX_API_KEY",
"models": ["MiniMax-M2.5"]
}"#;
let e: LlmProviderCatalogEntry = serde_json::from_str(raw).unwrap();
assert!(e.credential_schema.is_empty());
assert!(e.supported_auth_modes.is_empty());
assert!(!e.supports_models_probe);
}
#[test]
fn depends_on_satisfied_matches_value_in_allow_list() {
let d = DependsOn::any_of("auth_mode", &["setup_token", "api_key"]);
let mut fields: BTreeMap<String, String> = BTreeMap::new();
assert!(!d.satisfied(&fields), "missing key ⇒ unsatisfied");
fields.insert("auth_mode".into(), "oauth_auth_code".into());
assert!(
!d.satisfied(&fields),
"value not in allow-list ⇒ unsatisfied"
);
fields.insert("auth_mode".into(), "setup_token".into());
assert!(d.satisfied(&fields), "value in allow-list ⇒ satisfied");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_summary_round_trip() {
let s = LlmProviderSummary {
id: "minimax".into(),
base_url: "https://api.minimax.chat/v1".into(),
api_key_env: "MINIMAX_API_KEY".into(),
tenant_scope: None,
};
let v = serde_json::to_value(&s).unwrap();
let back: LlmProviderSummary = serde_json::from_value(v).unwrap();
assert_eq!(s, back);
}
#[test]
fn upsert_input_default_empty_headers() {
let i = LlmProviderUpsertInput {
id: "minimax".into(),
base_url: "x".into(),
api_key_env: "Y".into(),
..Default::default()
};
let v = serde_json::to_value(&i).unwrap();
let back: LlmProviderUpsertInput = serde_json::from_value(v).unwrap();
assert_eq!(i, back);
}
#[test]
fn provider_summary_tenant_scope_round_trip() {
let with = LlmProviderSummary {
id: "minimax".into(),
base_url: "https://api.minimax.io".into(),
api_key_env: "MINIMAX_KEY_ACME".into(),
tenant_scope: Some("acme".into()),
};
let s = serde_json::to_string(&with).unwrap();
assert!(s.contains("\"tenant_scope\":\"acme\""));
let back: LlmProviderSummary = serde_json::from_str(&s).unwrap();
assert_eq!(back, with);
let without = LlmProviderSummary {
id: "minimax".into(),
base_url: "https://api.minimax.io".into(),
api_key_env: "MINIMAX_KEY_GLOBAL".into(),
tenant_scope: None,
};
let s = serde_json::to_string(&without).unwrap();
assert!(!s.contains("tenant_scope"));
}
#[test]
fn provider_summary_legacy_payload_deserialises() {
let raw = r#"{"id":"minimax","base_url":"https://x","api_key_env":"K"}"#;
let s: LlmProviderSummary = serde_json::from_str(raw).unwrap();
assert!(s.tenant_scope.is_none());
}
#[test]
fn upsert_input_with_tenant_id_round_trip() {
let i = LlmProviderUpsertInput {
id: "minimax".into(),
base_url: "https://api.minimax.io".into(),
api_key_env: "MINIMAX_KEY_ACME".into(),
tenant_id: Some("acme".into()),
..Default::default()
};
let s = serde_json::to_string(&i).unwrap();
assert!(s.contains("\"tenant_id\":\"acme\""));
let back: LlmProviderUpsertInput = serde_json::from_str(&s).unwrap();
assert_eq!(back, i);
}
#[test]
fn delete_params_with_tenant_id_round_trip() {
let p = LlmProvidersDeleteParams {
provider_id: "minimax".into(),
tenant_id: Some("acme".into()),
};
let s = serde_json::to_string(&p).unwrap();
assert!(s.contains("\"tenant_id\":\"acme\""));
let back: LlmProvidersDeleteParams = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
}
#[test]
fn list_filter_round_trip_with_and_without_tenant() {
let with = LlmProvidersListFilter {
tenant_id: Some("acme".into()),
};
let s = serde_json::to_string(&with).unwrap();
assert_eq!(s, r#"{"tenant_id":"acme"}"#);
let back: LlmProvidersListFilter = serde_json::from_str(&s).unwrap();
assert_eq!(back, with);
let without = LlmProvidersListFilter::default();
let s = serde_json::to_string(&without).unwrap();
assert_eq!(s, "{}", "tenant_id None must be omitted");
}
#[test]
fn probe_input_round_trip() {
let with = LlmProviderProbeInput {
provider_id: "minimax".into(),
tenant_id: Some("acme".into()),
};
let v = serde_json::to_value(&with).unwrap();
let back: LlmProviderProbeInput = serde_json::from_value(v).unwrap();
assert_eq!(back, with);
let without = LlmProviderProbeInput {
provider_id: "minimax".into(),
tenant_id: None,
};
let s = serde_json::to_string(&without).unwrap();
assert!(!s.contains("tenant_id"), "None tenant_id must be omitted");
}
#[test]
fn probe_response_round_trip() {
let r = LlmProviderProbeResponse {
ok: true,
status: 200,
latency_ms: 142,
model_count: Some(5),
model_names: Some(vec!["gpt-4o".into()]),
error: None,
};
let v = serde_json::to_value(&r).unwrap();
let back: LlmProviderProbeResponse = serde_json::from_value(v).unwrap();
assert_eq!(back, r);
}
#[test]
fn probe_method_constant() {
assert_eq!(LLM_PROVIDERS_PROBE_METHOD, "nexo/admin/llm_providers/probe");
}
}