use url::Url;
use super::routes::{Route, Segment};
use crate::{ZaiError, ZaiResult, client::error::codes};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiFamily {
PaasV4,
CodingPaasV4,
AgentV1,
LlmApplication,
ApplicationV2,
ApplicationV3,
Zrag,
Monitor,
Realtime,
}
impl ApiFamily {
pub const fn default_base(self) -> &'static str {
match self {
ApiFamily::PaasV4 => "https://open.bigmodel.cn/api/paas/v4",
ApiFamily::CodingPaasV4 => "https://open.bigmodel.cn/api/coding/paas/v4",
ApiFamily::AgentV1 => "https://open.bigmodel.cn/api/v1",
ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
"https://open.bigmodel.cn/api/llm-application/open"
},
ApiFamily::Zrag => "https://open.bigmodel.cn/api/zrag",
ApiFamily::Monitor => "https://open.bigmodel.cn/api/monitor",
ApiFamily::Realtime => "wss://open.bigmodel.cn/api/paas/v4/realtime",
}
}
pub const fn is_realtime(self) -> bool {
matches!(self, ApiFamily::Realtime)
}
pub const fn secure_scheme(self) -> &'static str {
if self.is_realtime() { "wss" } else { "https" }
}
pub const fn insecure_scheme(self) -> &'static str {
if self.is_realtime() { "ws" } else { "http" }
}
}
#[derive(Clone)]
pub struct EndpointConfig {
paas_v4: Url,
coding_paas_v4: Url,
agent_v1: Url,
llm_application: Url,
zrag: Url,
monitor: Url,
realtime: Url,
}
impl std::fmt::Debug for EndpointConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EndpointConfig")
.field("paas_v4", &self.paas_v4.as_str())
.field("coding_paas_v4", &self.coding_paas_v4.as_str())
.field("agent_v1", &self.agent_v1.as_str())
.field("llm_application", &self.llm_application.as_str())
.field("zrag", &self.zrag.as_str())
.field("monitor", &self.monitor.as_str())
.field("realtime", &self.realtime.as_str())
.finish()
}
}
impl EndpointConfig {
pub fn defaults() -> ZaiResult<Self> {
Self::builder().build(false)
}
pub fn builder() -> EndpointConfigBuilder {
EndpointConfigBuilder {
paas_v4: ApiFamily::PaasV4.default_base().to_string(),
coding_paas_v4: ApiFamily::CodingPaasV4.default_base().to_string(),
agent_v1: ApiFamily::AgentV1.default_base().to_string(),
llm_application: ApiFamily::LlmApplication.default_base().to_string(),
zrag: ApiFamily::Zrag.default_base().to_string(),
monitor: ApiFamily::Monitor.default_base().to_string(),
realtime: ApiFamily::Realtime.default_base().to_string(),
}
}
pub fn with_base(
mut self,
family: ApiFamily,
base: impl AsRef<str>,
allow_insecure: bool,
) -> ZaiResult<Self> {
let parsed = parse_family_base(base.as_ref(), family, allow_insecure)?;
match family {
ApiFamily::PaasV4 => self.paas_v4 = parsed,
ApiFamily::CodingPaasV4 => self.coding_paas_v4 = parsed,
ApiFamily::AgentV1 => self.agent_v1 = parsed,
ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
self.llm_application = parsed;
},
ApiFamily::Zrag => self.zrag = parsed,
ApiFamily::Monitor => self.monitor = parsed,
ApiFamily::Realtime => self.realtime = parsed,
}
Ok(self)
}
pub fn base(&self, family: ApiFamily) -> &Url {
match family {
ApiFamily::PaasV4 => &self.paas_v4,
ApiFamily::CodingPaasV4 => &self.coding_paas_v4,
ApiFamily::AgentV1 => &self.agent_v1,
ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
&self.llm_application
},
ApiFamily::Zrag => &self.zrag,
ApiFamily::Monitor => &self.monitor,
ApiFamily::Realtime => &self.realtime,
}
}
pub fn resolve(&self, family: ApiFamily, segments: &[&str]) -> ZaiResult<String> {
let mut url = self.base(family).clone();
if segments.is_empty() {
return Ok(url.to_string());
}
for seg in segments {
validate_segment(seg)?;
}
let mut path = url
.path_segments_mut()
.map_err(|_| invalid("base URL cannot be a base"))?;
for seg in segments {
path.push(seg);
}
drop(path);
Ok(url.to_string())
}
pub fn resolve_with_query(
&self,
family: ApiFamily,
segments: &[&str],
query: &[(&str, &str)],
) -> ZaiResult<String> {
let mut url = self.base(family).clone();
if !segments.is_empty() {
for seg in segments {
validate_segment(seg)?;
}
let mut path = url
.path_segments_mut()
.map_err(|_| invalid("base URL cannot be a base"))?;
for seg in segments {
path.push(seg);
}
}
if !query.is_empty() {
let mut pairs = url.query_pairs_mut();
for (k, v) in query {
pairs.append_pair(k, v);
}
}
Ok(url.to_string())
}
pub(crate) fn resolve_route(&self, route: Route, parameters: &[&str]) -> ZaiResult<String> {
self.resolve_route_with_query(route, parameters, &[])
}
pub(crate) fn resolve_route_with_query(
&self,
route: Route,
parameters: &[&str],
query: &[(&str, &str)],
) -> ZaiResult<String> {
for parameter in parameters {
validate_segment(parameter)?;
}
let expected = route
.segments()
.iter()
.filter(|segment| matches!(segment, Segment::Parameter))
.count();
if parameters.len() != expected {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: format!(
"route {} expects {expected} path parameter(s), got {}",
route.operation_id(),
parameters.len()
),
});
}
let mut url = self.base(route.family()).clone();
if !route.segments().is_empty() {
let mut parameter_index = 0;
let mut path = url
.path_segments_mut()
.map_err(|_| invalid("base URL cannot be a base"))?;
for segment in route.segments() {
match segment {
Segment::Static(value) => {
path.push(value);
},
Segment::Parameter => {
path.push(parameters[parameter_index]);
parameter_index += 1;
},
}
}
}
if !query.is_empty() {
let mut pairs = url.query_pairs_mut();
for (key, value) in query {
pairs.append_pair(key, value);
}
}
Ok(url.to_string())
}
}
pub struct EndpointConfigBuilder {
paas_v4: String,
coding_paas_v4: String,
agent_v1: String,
llm_application: String,
zrag: String,
monitor: String,
realtime: String,
}
impl EndpointConfigBuilder {
pub fn paas_v4(mut self, base: impl Into<String>) -> Self {
self.paas_v4 = base.into();
self
}
pub fn coding_paas_v4(mut self, base: impl Into<String>) -> Self {
self.coding_paas_v4 = base.into();
self
}
pub fn agent_v1(mut self, base: impl Into<String>) -> Self {
self.agent_v1 = base.into();
self
}
pub fn llm_application(mut self, base: impl Into<String>) -> Self {
self.llm_application = base.into();
self
}
pub fn zrag(mut self, base: impl Into<String>) -> Self {
self.zrag = base.into();
self
}
pub fn monitor(mut self, base: impl Into<String>) -> Self {
self.monitor = base.into();
self
}
pub fn realtime(mut self, base: impl Into<String>) -> Self {
self.realtime = base.into();
self
}
pub fn build(self, allow_insecure: bool) -> ZaiResult<EndpointConfig> {
Ok(EndpointConfig {
paas_v4: parse_family_base(&self.paas_v4, ApiFamily::PaasV4, allow_insecure)?,
coding_paas_v4: parse_family_base(
&self.coding_paas_v4,
ApiFamily::CodingPaasV4,
allow_insecure,
)?,
agent_v1: parse_family_base(&self.agent_v1, ApiFamily::AgentV1, allow_insecure)?,
llm_application: parse_family_base(
&self.llm_application,
ApiFamily::LlmApplication,
allow_insecure,
)?,
zrag: parse_family_base(&self.zrag, ApiFamily::Zrag, allow_insecure)?,
monitor: parse_family_base(&self.monitor, ApiFamily::Monitor, allow_insecure)?,
realtime: parse_family_base(&self.realtime, ApiFamily::Realtime, allow_insecure)?,
})
}
}
fn parse_family_base(raw: &str, family: ApiFamily, allow_insecure: bool) -> ZaiResult<Url> {
let mut url =
Url::parse(raw).map_err(|error| invalid(&format!("invalid base URL: {error}")))?;
if url.cannot_be_a_base() {
return Err(invalid("base URL must be absolute, not a relative URL"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid("base URL must not contain userinfo"));
}
if url.query().is_some() {
return Err(invalid("base URL must not contain a query string"));
}
if url.fragment().is_some() {
return Err(invalid("base URL must not contain a fragment"));
}
let scheme = url.scheme();
let host = url
.host()
.ok_or_else(|| invalid("base URL must contain a host"))?;
if scheme == family.secure_scheme() {
} else if allow_insecure && scheme == family.insecure_scheme() {
if !is_loopback(host) {
return Err(invalid(&format!(
"insecure {scheme} transport is only allowed for loopback/localhost"
)));
}
} else {
return Err(invalid(&format!(
"family {:?} requires scheme {} (or {} on loopback); got {scheme:?}",
family,
family.secure_scheme(),
family.insecure_scheme()
)));
}
while url.path().len() > 1 && url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| invalid("base URL cannot be a base"))?
.pop_if_empty();
}
Ok(url)
}
fn is_loopback(host: url::Host<&str>) -> bool {
match host {
url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
url::Host::Ipv4(address) => address.is_loopback(),
url::Host::Ipv6(address) => address.is_loopback(),
}
}
fn validate_segment(seg: &str) -> ZaiResult<()> {
if seg.is_empty() {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: "path segment must not be empty".to_string(),
});
}
if seg == "." || seg == ".." {
return Err(ZaiError::ApiError {
code: codes::SDK_VALIDATION,
message: format!("path segment must not be `{seg}`"),
});
}
Ok(())
}
fn invalid(msg: &str) -> ZaiError {
ZaiError::ApiError {
code: codes::SDK_CONFIG,
message: msg.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_official_secure_urls() {
let ec = EndpointConfig::defaults().unwrap();
assert_eq!(
ec.base(ApiFamily::PaasV4).as_str(),
"https://open.bigmodel.cn/api/paas/v4"
);
assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "wss",);
}
#[test]
fn resolve_percent_encodes_dynamic_segments() {
let ec = EndpointConfig::defaults().unwrap();
let url = ec.resolve(ApiFamily::PaasV4, &["a/b"]).unwrap();
assert!(url.contains("a%2Fb"), "segment was not encoded: {url}");
let base = ec.base(ApiFamily::PaasV4).as_str();
assert!(url.starts_with(base));
}
#[test]
fn resolve_rejects_empty_dot_dotdot_segments() {
let ec = EndpointConfig::defaults().unwrap();
assert!(ec.resolve(ApiFamily::PaasV4, &[""]).is_err());
assert!(ec.resolve(ApiFamily::PaasV4, &["."]).is_err());
assert!(ec.resolve(ApiFamily::PaasV4, &[".."]).is_err());
}
#[test]
fn resolve_with_query_appends_pairs() {
let ec = EndpointConfig::defaults().unwrap();
let url = ec
.resolve_with_query(
ApiFamily::PaasV4,
&["files"],
&[("limit", "10"), ("order", "desc")],
)
.unwrap();
assert!(url.contains("limit=10"));
assert!(url.contains("order=desc"));
}
#[test]
fn canonical_route_encodes_parameters_and_query() {
let ec = EndpointConfig::defaults().unwrap();
let url = ec
.resolve_route_with_query(
crate::client::routes::FILES_PARSE_RESULT,
&["task/with/slash", "md"],
&[("name", "a&b")],
)
.unwrap();
assert_eq!(
url,
"https://open.bigmodel.cn/api/paas/v4/files/parser/result/task%2Fwith%2Fslash/md?name=a%26b"
);
}
#[test]
fn trailing_slash_base_does_not_create_an_empty_route_segment() {
let endpoints = EndpointConfig::builder()
.paas_v4("http://127.0.0.1:8080/api/paas/v4/")
.build(true)
.unwrap();
let url = endpoints
.resolve_route(crate::client::routes::CHAT_COMPLETE, &[])
.unwrap();
assert_eq!(url, "http://127.0.0.1:8080/api/paas/v4/chat/completions");
}
#[test]
fn canonical_route_rejects_parameter_count_mismatch() {
let ec = EndpointConfig::defaults().unwrap();
let error = ec
.resolve_route(crate::client::routes::FILES_GET_CONTENT, &[])
.unwrap_err();
assert!(error.message().contains("expects 1 path parameter"));
}
#[test]
fn rejects_relative_userinfo_query_fragment() {
assert!(
EndpointConfig::builder()
.paas_v4("not/a/url")
.build(true)
.is_err()
);
assert!(
EndpointConfig::builder()
.paas_v4("https://user:pass@open.bigmodel.cn/api/paas/v4")
.build(false)
.is_err()
);
assert!(
EndpointConfig::builder()
.paas_v4("https://open.bigmodel.cn/api/paas/v4?x=1")
.build(false)
.is_err()
);
assert!(
EndpointConfig::builder()
.paas_v4("https://open.bigmodel.cn/api/paas/v4#frag")
.build(false)
.is_err()
);
}
#[test]
fn rejects_http_public_host_without_insecure() {
assert!(
EndpointConfig::builder()
.paas_v4("http://open.bigmodel.cn/api/paas/v4")
.build(false)
.is_err()
);
}
#[test]
fn http_loopback_allowed_when_insecure_enabled() {
let ec = EndpointConfig::builder()
.paas_v4("http://127.0.0.1:8080/api/paas/v4")
.build(true)
.unwrap();
assert_eq!(ec.base(ApiFamily::PaasV4).scheme(), "http");
assert_eq!(ec.base(ApiFamily::PaasV4).host_str(), Some("127.0.0.1"));
}
#[test]
fn http_public_host_rejected_even_when_insecure_enabled() {
assert!(
EndpointConfig::builder()
.paas_v4("http://open.bigmodel.cn/api/paas/v4")
.build(true)
.is_err()
);
}
#[test]
fn insecure_dns_name_with_127_prefix_is_rejected() {
assert!(
EndpointConfig::builder()
.paas_v4("http://127.evil.example/api/paas/v4")
.build(true)
.is_err()
);
}
#[test]
fn replacing_one_base_preserves_the_rest() {
let defaults = EndpointConfig::defaults().unwrap();
let updated = defaults
.clone()
.with_base(
ApiFamily::Realtime,
"wss://example.com/custom-realtime",
false,
)
.unwrap();
assert_eq!(
updated.base(ApiFamily::PaasV4),
defaults.base(ApiFamily::PaasV4)
);
assert_eq!(
updated.base(ApiFamily::Realtime).as_str(),
"wss://example.com/custom-realtime"
);
}
#[test]
fn ws_loopback_allowed_for_realtime_when_insecure() {
let ec = EndpointConfig::builder()
.realtime("ws://localhost:9000/realtime")
.build(true)
.unwrap();
assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "ws");
}
}