1use url::Url;
13
14use super::routes::{Route, Segment};
15use crate::{ZaiError, ZaiResult, client::error::codes};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ApiFamily {
25 PaasV4,
27 CodingPaasV4,
29 AgentV1,
31 LlmApplication,
33 ApplicationV2,
35 ApplicationV3,
37 Zrag,
39 Monitor,
41 Realtime,
43}
44
45impl ApiFamily {
46 pub const fn default_base(self) -> &'static str {
48 match self {
49 ApiFamily::PaasV4 => "https://open.bigmodel.cn/api/paas/v4",
50 ApiFamily::CodingPaasV4 => "https://open.bigmodel.cn/api/coding/paas/v4",
51 ApiFamily::AgentV1 => "https://open.bigmodel.cn/api/v1",
52 ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
55 "https://open.bigmodel.cn/api/llm-application/open"
56 },
57 ApiFamily::Zrag => "https://open.bigmodel.cn/api/zrag",
58 ApiFamily::Monitor => "https://open.bigmodel.cn/api/monitor",
59 ApiFamily::Realtime => "wss://open.bigmodel.cn/api/paas/v4/realtime",
60 }
61 }
62
63 pub const fn is_realtime(self) -> bool {
65 matches!(self, ApiFamily::Realtime)
66 }
67
68 pub const fn secure_scheme(self) -> &'static str {
70 if self.is_realtime() { "wss" } else { "https" }
71 }
72
73 pub const fn insecure_scheme(self) -> &'static str {
75 if self.is_realtime() { "ws" } else { "http" }
76 }
77}
78
79#[derive(Clone)]
85pub struct EndpointConfig {
86 paas_v4: Url,
87 coding_paas_v4: Url,
88 agent_v1: Url,
89 llm_application: Url,
90 zrag: Url,
91 monitor: Url,
92 realtime: Url,
93}
94
95impl std::fmt::Debug for EndpointConfig {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("EndpointConfig")
98 .field("paas_v4", &self.paas_v4.as_str())
99 .field("coding_paas_v4", &self.coding_paas_v4.as_str())
100 .field("agent_v1", &self.agent_v1.as_str())
101 .field("llm_application", &self.llm_application.as_str())
102 .field("zrag", &self.zrag.as_str())
103 .field("monitor", &self.monitor.as_str())
104 .field("realtime", &self.realtime.as_str())
105 .finish()
106 }
107}
108
109impl EndpointConfig {
110 pub fn defaults() -> ZaiResult<Self> {
112 Self::builder().build(false)
113 }
114
115 pub fn builder() -> EndpointConfigBuilder {
117 EndpointConfigBuilder {
118 paas_v4: ApiFamily::PaasV4.default_base().to_string(),
119 coding_paas_v4: ApiFamily::CodingPaasV4.default_base().to_string(),
120 agent_v1: ApiFamily::AgentV1.default_base().to_string(),
121 llm_application: ApiFamily::LlmApplication.default_base().to_string(),
122 zrag: ApiFamily::Zrag.default_base().to_string(),
123 monitor: ApiFamily::Monitor.default_base().to_string(),
124 realtime: ApiFamily::Realtime.default_base().to_string(),
125 }
126 }
127
128 pub fn with_base(
132 mut self,
133 family: ApiFamily,
134 base: impl AsRef<str>,
135 allow_insecure: bool,
136 ) -> ZaiResult<Self> {
137 let parsed = parse_family_base(base.as_ref(), family, allow_insecure)?;
138 match family {
139 ApiFamily::PaasV4 => self.paas_v4 = parsed,
140 ApiFamily::CodingPaasV4 => self.coding_paas_v4 = parsed,
141 ApiFamily::AgentV1 => self.agent_v1 = parsed,
142 ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
143 self.llm_application = parsed;
144 },
145 ApiFamily::Zrag => self.zrag = parsed,
146 ApiFamily::Monitor => self.monitor = parsed,
147 ApiFamily::Realtime => self.realtime = parsed,
148 }
149 Ok(self)
150 }
151
152 pub fn base(&self, family: ApiFamily) -> &Url {
154 match family {
155 ApiFamily::PaasV4 => &self.paas_v4,
156 ApiFamily::CodingPaasV4 => &self.coding_paas_v4,
157 ApiFamily::AgentV1 => &self.agent_v1,
158 ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
159 &self.llm_application
160 },
161 ApiFamily::Zrag => &self.zrag,
162 ApiFamily::Monitor => &self.monitor,
163 ApiFamily::Realtime => &self.realtime,
164 }
165 }
166
167 pub fn resolve(&self, family: ApiFamily, segments: &[&str]) -> ZaiResult<String> {
173 let mut url = self.base(family).clone();
174 if segments.is_empty() {
175 return Ok(url.to_string());
176 }
177 for seg in segments {
180 validate_segment(seg)?;
181 }
182 let mut path = url
183 .path_segments_mut()
184 .map_err(|_| invalid("base URL cannot be a base"))?;
185 for seg in segments {
186 path.push(seg);
187 }
188 drop(path);
189 Ok(url.to_string())
190 }
191
192 pub fn resolve_with_query(
194 &self,
195 family: ApiFamily,
196 segments: &[&str],
197 query: &[(&str, &str)],
198 ) -> ZaiResult<String> {
199 let mut url = self.base(family).clone();
200 if !segments.is_empty() {
201 for seg in segments {
202 validate_segment(seg)?;
203 }
204 let mut path = url
205 .path_segments_mut()
206 .map_err(|_| invalid("base URL cannot be a base"))?;
207 for seg in segments {
208 path.push(seg);
209 }
210 }
211 if !query.is_empty() {
212 let mut pairs = url.query_pairs_mut();
213 for (k, v) in query {
214 pairs.append_pair(k, v);
215 }
216 }
217 Ok(url.to_string())
218 }
219
220 pub(crate) fn resolve_route(&self, route: Route, parameters: &[&str]) -> ZaiResult<String> {
226 self.resolve_route_with_query(route, parameters, &[])
227 }
228
229 pub(crate) fn resolve_route_with_query(
231 &self,
232 route: Route,
233 parameters: &[&str],
234 query: &[(&str, &str)],
235 ) -> ZaiResult<String> {
236 for parameter in parameters {
237 validate_segment(parameter)?;
238 }
239
240 let expected = route
241 .segments()
242 .iter()
243 .filter(|segment| matches!(segment, Segment::Parameter))
244 .count();
245 if parameters.len() != expected {
246 return Err(ZaiError::ApiError {
247 code: codes::SDK_VALIDATION,
248 message: format!(
249 "route {} expects {expected} path parameter(s), got {}",
250 route.operation_id(),
251 parameters.len()
252 ),
253 });
254 }
255
256 let mut url = self.base(route.family()).clone();
257 if !route.segments().is_empty() {
258 let mut parameter_index = 0;
259 let mut path = url
260 .path_segments_mut()
261 .map_err(|_| invalid("base URL cannot be a base"))?;
262 for segment in route.segments() {
263 match segment {
264 Segment::Static(value) => {
265 path.push(value);
266 },
267 Segment::Parameter => {
268 path.push(parameters[parameter_index]);
269 parameter_index += 1;
270 },
271 }
272 }
273 }
274 if !query.is_empty() {
275 let mut pairs = url.query_pairs_mut();
276 for (key, value) in query {
277 pairs.append_pair(key, value);
278 }
279 }
280 Ok(url.to_string())
281 }
282}
283
284pub struct EndpointConfigBuilder {
286 paas_v4: String,
287 coding_paas_v4: String,
288 agent_v1: String,
289 llm_application: String,
290 zrag: String,
291 monitor: String,
292 realtime: String,
293}
294
295impl EndpointConfigBuilder {
296 pub fn paas_v4(mut self, base: impl Into<String>) -> Self {
298 self.paas_v4 = base.into();
299 self
300 }
301 pub fn coding_paas_v4(mut self, base: impl Into<String>) -> Self {
303 self.coding_paas_v4 = base.into();
304 self
305 }
306 pub fn agent_v1(mut self, base: impl Into<String>) -> Self {
308 self.agent_v1 = base.into();
309 self
310 }
311 pub fn llm_application(mut self, base: impl Into<String>) -> Self {
313 self.llm_application = base.into();
314 self
315 }
316 pub fn zrag(mut self, base: impl Into<String>) -> Self {
318 self.zrag = base.into();
319 self
320 }
321 pub fn monitor(mut self, base: impl Into<String>) -> Self {
323 self.monitor = base.into();
324 self
325 }
326 pub fn realtime(mut self, base: impl Into<String>) -> Self {
328 self.realtime = base.into();
329 self
330 }
331
332 pub fn build(self, allow_insecure: bool) -> ZaiResult<EndpointConfig> {
335 Ok(EndpointConfig {
336 paas_v4: parse_family_base(&self.paas_v4, ApiFamily::PaasV4, allow_insecure)?,
337 coding_paas_v4: parse_family_base(
338 &self.coding_paas_v4,
339 ApiFamily::CodingPaasV4,
340 allow_insecure,
341 )?,
342 agent_v1: parse_family_base(&self.agent_v1, ApiFamily::AgentV1, allow_insecure)?,
343 llm_application: parse_family_base(
344 &self.llm_application,
345 ApiFamily::LlmApplication,
346 allow_insecure,
347 )?,
348 zrag: parse_family_base(&self.zrag, ApiFamily::Zrag, allow_insecure)?,
349 monitor: parse_family_base(&self.monitor, ApiFamily::Monitor, allow_insecure)?,
350 realtime: parse_family_base(&self.realtime, ApiFamily::Realtime, allow_insecure)?,
351 })
352 }
353}
354
355fn parse_family_base(raw: &str, family: ApiFamily, allow_insecure: bool) -> ZaiResult<Url> {
363 let mut url =
366 Url::parse(raw).map_err(|error| invalid(&format!("invalid base URL: {error}")))?;
367
368 if url.cannot_be_a_base() {
369 return Err(invalid("base URL must be absolute, not a relative URL"));
370 }
371 if !url.username().is_empty() || url.password().is_some() {
372 return Err(invalid("base URL must not contain userinfo"));
373 }
374 if url.query().is_some() {
375 return Err(invalid("base URL must not contain a query string"));
376 }
377 if url.fragment().is_some() {
378 return Err(invalid("base URL must not contain a fragment"));
379 }
380
381 let scheme = url.scheme();
382 let host = url
383 .host()
384 .ok_or_else(|| invalid("base URL must contain a host"))?;
385 if scheme == family.secure_scheme() {
386 } else if allow_insecure && scheme == family.insecure_scheme() {
388 if !is_loopback(host) {
390 return Err(invalid(&format!(
391 "insecure {scheme} transport is only allowed for loopback/localhost"
392 )));
393 }
394 } else {
395 return Err(invalid(&format!(
396 "family {:?} requires scheme {} (or {} on loopback); got {scheme:?}",
397 family,
398 family.secure_scheme(),
399 family.insecure_scheme()
400 )));
401 }
402
403 while url.path().len() > 1 && url.path().ends_with('/') {
408 url.path_segments_mut()
409 .map_err(|_| invalid("base URL cannot be a base"))?
410 .pop_if_empty();
411 }
412
413 Ok(url)
414}
415
416fn is_loopback(host: url::Host<&str>) -> bool {
421 match host {
422 url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
423 url::Host::Ipv4(address) => address.is_loopback(),
424 url::Host::Ipv6(address) => address.is_loopback(),
425 }
426}
427
428fn validate_segment(seg: &str) -> ZaiResult<()> {
430 if seg.is_empty() {
431 return Err(ZaiError::ApiError {
432 code: codes::SDK_VALIDATION,
433 message: "path segment must not be empty".to_string(),
434 });
435 }
436 if seg == "." || seg == ".." {
437 return Err(ZaiError::ApiError {
438 code: codes::SDK_VALIDATION,
439 message: format!("path segment must not be `{seg}`"),
440 });
441 }
442 Ok(())
443}
444
445fn invalid(msg: &str) -> ZaiError {
446 ZaiError::ApiError {
447 code: codes::SDK_CONFIG,
448 message: msg.to_string(),
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn defaults_are_official_secure_urls() {
458 let ec = EndpointConfig::defaults().unwrap();
459 assert_eq!(
460 ec.base(ApiFamily::PaasV4).as_str(),
461 "https://open.bigmodel.cn/api/paas/v4"
462 );
463 assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "wss",);
464 }
465
466 #[test]
467 fn resolve_percent_encodes_dynamic_segments() {
468 let ec = EndpointConfig::defaults().unwrap();
469 let url = ec.resolve(ApiFamily::PaasV4, &["a/b"]).unwrap();
471 assert!(url.contains("a%2Fb"), "segment was not encoded: {url}");
472 let base = ec.base(ApiFamily::PaasV4).as_str();
474 assert!(url.starts_with(base));
475 }
476
477 #[test]
478 fn resolve_rejects_empty_dot_dotdot_segments() {
479 let ec = EndpointConfig::defaults().unwrap();
480 assert!(ec.resolve(ApiFamily::PaasV4, &[""]).is_err());
481 assert!(ec.resolve(ApiFamily::PaasV4, &["."]).is_err());
482 assert!(ec.resolve(ApiFamily::PaasV4, &[".."]).is_err());
483 }
484
485 #[test]
486 fn resolve_with_query_appends_pairs() {
487 let ec = EndpointConfig::defaults().unwrap();
488 let url = ec
489 .resolve_with_query(
490 ApiFamily::PaasV4,
491 &["files"],
492 &[("limit", "10"), ("order", "desc")],
493 )
494 .unwrap();
495 assert!(url.contains("limit=10"));
496 assert!(url.contains("order=desc"));
497 }
498
499 #[test]
500 fn canonical_route_encodes_parameters_and_query() {
501 let ec = EndpointConfig::defaults().unwrap();
502 let url = ec
503 .resolve_route_with_query(
504 crate::client::routes::FILES_PARSE_RESULT,
505 &["task/with/slash", "md"],
506 &[("name", "a&b")],
507 )
508 .unwrap();
509 assert_eq!(
510 url,
511 "https://open.bigmodel.cn/api/paas/v4/files/parser/result/task%2Fwith%2Fslash/md?name=a%26b"
512 );
513 }
514
515 #[test]
516 fn trailing_slash_base_does_not_create_an_empty_route_segment() {
517 let endpoints = EndpointConfig::builder()
518 .paas_v4("http://127.0.0.1:8080/api/paas/v4/")
519 .build(true)
520 .unwrap();
521 let url = endpoints
522 .resolve_route(crate::client::routes::CHAT_COMPLETE, &[])
523 .unwrap();
524 assert_eq!(url, "http://127.0.0.1:8080/api/paas/v4/chat/completions");
525 }
526
527 #[test]
528 fn canonical_route_rejects_parameter_count_mismatch() {
529 let ec = EndpointConfig::defaults().unwrap();
530 let error = ec
531 .resolve_route(crate::client::routes::FILES_GET_CONTENT, &[])
532 .unwrap_err();
533 assert!(error.message().contains("expects 1 path parameter"));
534 }
535
536 #[test]
537 fn rejects_relative_userinfo_query_fragment() {
538 assert!(
540 EndpointConfig::builder()
541 .paas_v4("not/a/url")
542 .build(true)
543 .is_err()
544 );
545 assert!(
547 EndpointConfig::builder()
548 .paas_v4("https://user:pass@open.bigmodel.cn/api/paas/v4")
549 .build(false)
550 .is_err()
551 );
552 assert!(
554 EndpointConfig::builder()
555 .paas_v4("https://open.bigmodel.cn/api/paas/v4?x=1")
556 .build(false)
557 .is_err()
558 );
559 assert!(
561 EndpointConfig::builder()
562 .paas_v4("https://open.bigmodel.cn/api/paas/v4#frag")
563 .build(false)
564 .is_err()
565 );
566 }
567
568 #[test]
569 fn rejects_http_public_host_without_insecure() {
570 assert!(
572 EndpointConfig::builder()
573 .paas_v4("http://open.bigmodel.cn/api/paas/v4")
574 .build(false)
575 .is_err()
576 );
577 }
578
579 #[test]
580 fn http_loopback_allowed_when_insecure_enabled() {
581 let ec = EndpointConfig::builder()
582 .paas_v4("http://127.0.0.1:8080/api/paas/v4")
583 .build(true)
584 .unwrap();
585 assert_eq!(ec.base(ApiFamily::PaasV4).scheme(), "http");
586 assert_eq!(ec.base(ApiFamily::PaasV4).host_str(), Some("127.0.0.1"));
587 }
588
589 #[test]
590 fn http_public_host_rejected_even_when_insecure_enabled() {
591 assert!(
593 EndpointConfig::builder()
594 .paas_v4("http://open.bigmodel.cn/api/paas/v4")
595 .build(true)
596 .is_err()
597 );
598 }
599
600 #[test]
601 fn insecure_dns_name_with_127_prefix_is_rejected() {
602 assert!(
603 EndpointConfig::builder()
604 .paas_v4("http://127.evil.example/api/paas/v4")
605 .build(true)
606 .is_err()
607 );
608 }
609
610 #[test]
611 fn replacing_one_base_preserves_the_rest() {
612 let defaults = EndpointConfig::defaults().unwrap();
613 let updated = defaults
614 .clone()
615 .with_base(
616 ApiFamily::Realtime,
617 "wss://example.com/custom-realtime",
618 false,
619 )
620 .unwrap();
621 assert_eq!(
622 updated.base(ApiFamily::PaasV4),
623 defaults.base(ApiFamily::PaasV4)
624 );
625 assert_eq!(
626 updated.base(ApiFamily::Realtime).as_str(),
627 "wss://example.com/custom-realtime"
628 );
629 }
630
631 #[test]
632 fn ws_loopback_allowed_for_realtime_when_insecure() {
633 let ec = EndpointConfig::builder()
634 .realtime("ws://localhost:9000/realtime")
635 .build(true)
636 .unwrap();
637 assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "ws");
638 }
639}