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,
26 CodingPaasV4,
27 AgentV1,
28 LlmApplication,
29 ApplicationV2,
30 ApplicationV3,
31 Zrag,
32 Monitor,
33 Realtime,
35}
36
37impl ApiFamily {
38 pub const fn default_base(self) -> &'static str {
40 match self {
41 ApiFamily::PaasV4 => "https://open.bigmodel.cn/api/paas/v4",
42 ApiFamily::CodingPaasV4 => "https://open.bigmodel.cn/api/coding/paas/v4",
43 ApiFamily::AgentV1 => "https://open.bigmodel.cn/api/v1",
44 ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
46 "https://open.bigmodel.cn/api/llm-application/open"
47 },
48 ApiFamily::Zrag => "https://open.bigmodel.cn/api/zrag",
49 ApiFamily::Monitor => "https://open.bigmodel.cn/api/monitor",
50 ApiFamily::Realtime => "wss://open.bigmodel.cn/api/paas/v4/realtime",
51 }
52 }
53
54 pub const fn is_realtime(self) -> bool {
56 matches!(self, ApiFamily::Realtime)
57 }
58
59 pub const fn secure_scheme(self) -> &'static str {
61 if self.is_realtime() { "wss" } else { "https" }
62 }
63
64 pub const fn insecure_scheme(self) -> &'static str {
66 if self.is_realtime() { "ws" } else { "http" }
67 }
68}
69
70#[derive(Clone)]
76pub struct EndpointConfig {
77 paas_v4: Url,
78 coding_paas_v4: Url,
79 agent_v1: Url,
80 llm_application: Url,
81 zrag: Url,
82 monitor: Url,
83 realtime: Url,
84}
85
86impl std::fmt::Debug for EndpointConfig {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("EndpointConfig")
89 .field("paas_v4", &self.paas_v4.as_str())
90 .field("coding_paas_v4", &self.coding_paas_v4.as_str())
91 .field("agent_v1", &self.agent_v1.as_str())
92 .field("llm_application", &self.llm_application.as_str())
93 .field("zrag", &self.zrag.as_str())
94 .field("monitor", &self.monitor.as_str())
95 .field("realtime", &self.realtime.as_str())
96 .finish()
97 }
98}
99
100impl EndpointConfig {
101 pub fn defaults() -> ZaiResult<Self> {
103 Self::builder().build(false)
104 }
105
106 pub fn builder() -> EndpointConfigBuilder {
108 EndpointConfigBuilder {
109 paas_v4: ApiFamily::PaasV4.default_base(),
110 coding_paas_v4: ApiFamily::CodingPaasV4.default_base(),
111 agent_v1: ApiFamily::AgentV1.default_base(),
112 llm_application: ApiFamily::LlmApplication.default_base(),
113 zrag: ApiFamily::Zrag.default_base(),
114 monitor: ApiFamily::Monitor.default_base(),
115 realtime: ApiFamily::Realtime.default_base(),
116 }
117 }
118
119 pub fn base(&self, family: ApiFamily) -> &Url {
121 match family {
122 ApiFamily::PaasV4 => &self.paas_v4,
123 ApiFamily::CodingPaasV4 => &self.coding_paas_v4,
124 ApiFamily::AgentV1 => &self.agent_v1,
125 ApiFamily::LlmApplication | ApiFamily::ApplicationV2 | ApiFamily::ApplicationV3 => {
126 &self.llm_application
127 },
128 ApiFamily::Zrag => &self.zrag,
129 ApiFamily::Monitor => &self.monitor,
130 ApiFamily::Realtime => &self.realtime,
131 }
132 }
133
134 pub fn resolve(&self, family: ApiFamily, segments: &[&str]) -> ZaiResult<String> {
140 let mut url = self.base(family).clone();
141 if segments.is_empty() {
142 return Ok(url.to_string());
143 }
144 for seg in segments {
147 validate_segment(seg)?;
148 }
149 let mut path = url
150 .path_segments_mut()
151 .map_err(|_| invalid("base URL cannot be a base"))?;
152 for seg in segments {
153 path.push(seg);
154 }
155 drop(path);
156 Ok(url.to_string())
157 }
158
159 pub fn resolve_with_query(
161 &self,
162 family: ApiFamily,
163 segments: &[&str],
164 query: &[(&str, &str)],
165 ) -> ZaiResult<String> {
166 let mut url = self.base(family).clone();
167 if !segments.is_empty() {
168 for seg in segments {
169 validate_segment(seg)?;
170 }
171 let mut path = url
172 .path_segments_mut()
173 .map_err(|_| invalid("base URL cannot be a base"))?;
174 for seg in segments {
175 path.push(seg);
176 }
177 }
178 if !query.is_empty() {
179 let mut pairs = url.query_pairs_mut();
180 for (k, v) in query {
181 pairs.append_pair(k, v);
182 }
183 }
184 Ok(url.to_string())
185 }
186
187 pub(crate) fn resolve_route(&self, route: Route, parameters: &[&str]) -> ZaiResult<String> {
193 self.resolve_route_with_query(route, parameters, &[])
194 }
195
196 pub(crate) fn resolve_route_with_query(
198 &self,
199 route: Route,
200 parameters: &[&str],
201 query: &[(&str, &str)],
202 ) -> ZaiResult<String> {
203 for parameter in parameters {
204 validate_segment(parameter)?;
205 }
206
207 let expected = route
208 .segments()
209 .iter()
210 .filter(|segment| matches!(segment, Segment::Parameter))
211 .count();
212 if parameters.len() != expected {
213 return Err(ZaiError::ApiError {
214 code: codes::SDK_VALIDATION,
215 message: format!(
216 "route {} expects {expected} path parameter(s), got {}",
217 route.operation_id(),
218 parameters.len()
219 ),
220 });
221 }
222
223 let mut url = self.base(route.family()).clone();
224 if !route.segments().is_empty() {
225 let mut parameter_index = 0;
226 let mut path = url
227 .path_segments_mut()
228 .map_err(|_| invalid("base URL cannot be a base"))?;
229 for segment in route.segments() {
230 match segment {
231 Segment::Static(value) => path.push(value),
232 Segment::Parameter => {
233 path.push(parameters[parameter_index]);
234 parameter_index += 1;
235 &mut path
236 },
237 };
238 }
239 }
240 if !query.is_empty() {
241 let mut pairs = url.query_pairs_mut();
242 for (key, value) in query {
243 pairs.append_pair(key, value);
244 }
245 }
246 Ok(url.to_string())
247 }
248}
249
250pub struct EndpointConfigBuilder {
252 paas_v4: &'static str,
253 coding_paas_v4: &'static str,
254 agent_v1: &'static str,
255 llm_application: &'static str,
256 zrag: &'static str,
257 monitor: &'static str,
258 realtime: &'static str,
259}
260
261impl EndpointConfigBuilder {
262 pub fn paas_v4(mut self, base: &'static str) -> Self {
264 self.paas_v4 = base;
265 self
266 }
267 pub fn coding_paas_v4(mut self, base: &'static str) -> Self {
269 self.coding_paas_v4 = base;
270 self
271 }
272 pub fn agent_v1(mut self, base: &'static str) -> Self {
274 self.agent_v1 = base;
275 self
276 }
277 pub fn llm_application(mut self, base: &'static str) -> Self {
279 self.llm_application = base;
280 self
281 }
282 pub fn zrag(mut self, base: &'static str) -> Self {
284 self.zrag = base;
285 self
286 }
287 pub fn monitor(mut self, base: &'static str) -> Self {
289 self.monitor = base;
290 self
291 }
292 pub fn realtime(mut self, base: &'static str) -> Self {
294 self.realtime = base;
295 self
296 }
297
298 pub fn build(self, allow_insecure: bool) -> ZaiResult<EndpointConfig> {
301 Ok(EndpointConfig {
302 paas_v4: parse_family_base(self.paas_v4, ApiFamily::PaasV4, allow_insecure)?,
303 coding_paas_v4: parse_family_base(
304 self.coding_paas_v4,
305 ApiFamily::CodingPaasV4,
306 allow_insecure,
307 )?,
308 agent_v1: parse_family_base(self.agent_v1, ApiFamily::AgentV1, allow_insecure)?,
309 llm_application: parse_family_base(
310 self.llm_application,
311 ApiFamily::LlmApplication,
312 allow_insecure,
313 )?,
314 zrag: parse_family_base(self.zrag, ApiFamily::Zrag, allow_insecure)?,
315 monitor: parse_family_base(self.monitor, ApiFamily::Monitor, allow_insecure)?,
316 realtime: parse_family_base(self.realtime, ApiFamily::Realtime, allow_insecure)?,
317 })
318 }
319}
320
321fn parse_family_base(raw: &str, family: ApiFamily, allow_insecure: bool) -> ZaiResult<Url> {
329 let url = Url::parse(raw).map_err(|e| invalid(&format!("invalid base URL {raw:?}: {e}")))?;
330
331 if url.cannot_be_a_base() {
332 return Err(invalid("base URL must be absolute, not a relative URL"));
333 }
334 if !url.username().is_empty() || url.password().is_some() {
335 return Err(invalid("base URL must not contain userinfo"));
336 }
337 if url.query().is_some() {
338 return Err(invalid("base URL must not contain a query string"));
339 }
340 if url.fragment().is_some() {
341 return Err(invalid("base URL must not contain a fragment"));
342 }
343
344 let scheme = url.scheme();
345 let host = url.host_str().unwrap_or("");
346 if scheme == family.secure_scheme() {
347 } else if allow_insecure && scheme == family.insecure_scheme() {
349 if !is_loopback(host) {
351 return Err(invalid(&format!(
352 "insecure {scheme} transport is only allowed for loopback/localhost, got host {host:?}"
353 )));
354 }
355 } else {
356 return Err(invalid(&format!(
357 "family {:?} requires scheme {} (or {} on loopback); got {scheme:?}",
358 family,
359 family.secure_scheme(),
360 family.insecure_scheme()
361 )));
362 }
363
364 Ok(url)
365}
366
367fn is_loopback(host: &str) -> bool {
370 host == "localhost"
371 || host == "127.0.0.1"
372 || host == "::1"
373 || host == "[::1]"
374 || host.starts_with("127.")
375}
376
377fn validate_segment(seg: &str) -> ZaiResult<()> {
379 if seg.is_empty() {
380 return Err(ZaiError::ApiError {
381 code: codes::SDK_VALIDATION,
382 message: "path segment must not be empty".to_string(),
383 });
384 }
385 if seg == "." || seg == ".." {
386 return Err(ZaiError::ApiError {
387 code: codes::SDK_VALIDATION,
388 message: format!("path segment must not be `{seg}`"),
389 });
390 }
391 Ok(())
392}
393
394fn invalid(msg: &str) -> ZaiError {
395 ZaiError::ApiError {
396 code: codes::SDK_CONFIG,
397 message: msg.to_string(),
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn defaults_are_official_secure_urls() {
407 let ec = EndpointConfig::defaults().unwrap();
408 assert_eq!(
409 ec.base(ApiFamily::PaasV4).as_str(),
410 "https://open.bigmodel.cn/api/paas/v4"
411 );
412 assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "wss",);
413 }
414
415 #[test]
416 fn resolve_percent_encodes_dynamic_segments() {
417 let ec = EndpointConfig::defaults().unwrap();
418 let url = ec.resolve(ApiFamily::PaasV4, &["a/b"]).unwrap();
420 assert!(
421 url.contains("a%2Fb") || url.contains("a/b"),
422 "segment should be encoded into the path: {url}"
423 );
424 let base = ec.base(ApiFamily::PaasV4).as_str();
426 assert!(url.starts_with(base));
427 }
428
429 #[test]
430 fn resolve_rejects_empty_dot_dotdot_segments() {
431 let ec = EndpointConfig::defaults().unwrap();
432 assert!(ec.resolve(ApiFamily::PaasV4, &[""]).is_err());
433 assert!(ec.resolve(ApiFamily::PaasV4, &["."]).is_err());
434 assert!(ec.resolve(ApiFamily::PaasV4, &[".."]).is_err());
435 }
436
437 #[test]
438 fn resolve_with_query_appends_pairs() {
439 let ec = EndpointConfig::defaults().unwrap();
440 let url = ec
441 .resolve_with_query(
442 ApiFamily::PaasV4,
443 &["files"],
444 &[("limit", "10"), ("order", "desc")],
445 )
446 .unwrap();
447 assert!(url.contains("limit=10"));
448 assert!(url.contains("order=desc"));
449 }
450
451 #[test]
452 fn canonical_route_encodes_parameters_and_query() {
453 let ec = EndpointConfig::defaults().unwrap();
454 let url = ec
455 .resolve_route_with_query(
456 crate::client::routes::FILES_PARSE_RESULT,
457 &["task/with/slash", "md"],
458 &[("name", "a&b")],
459 )
460 .unwrap();
461 assert_eq!(
462 url,
463 "https://open.bigmodel.cn/api/paas/v4/files/parser/result/task%2Fwith%2Fslash/md?name=a%26b"
464 );
465 }
466
467 #[test]
468 fn canonical_route_rejects_parameter_count_mismatch() {
469 let ec = EndpointConfig::defaults().unwrap();
470 let error = ec
471 .resolve_route(crate::client::routes::FILES_GET_CONTENT, &[])
472 .unwrap_err();
473 assert!(error.message().contains("expects 1 path parameter"));
474 }
475
476 #[test]
477 fn rejects_relative_userinfo_query_fragment() {
478 assert!(
480 EndpointConfig::builder()
481 .paas_v4("not/a/url")
482 .build(true)
483 .is_err()
484 );
485 assert!(
487 EndpointConfig::builder()
488 .paas_v4("https://user:pass@open.bigmodel.cn/api/paas/v4")
489 .build(false)
490 .is_err()
491 );
492 assert!(
494 EndpointConfig::builder()
495 .paas_v4("https://open.bigmodel.cn/api/paas/v4?x=1")
496 .build(false)
497 .is_err()
498 );
499 assert!(
501 EndpointConfig::builder()
502 .paas_v4("https://open.bigmodel.cn/api/paas/v4#frag")
503 .build(false)
504 .is_err()
505 );
506 }
507
508 #[test]
509 fn rejects_http_public_host_without_insecure() {
510 assert!(
512 EndpointConfig::builder()
513 .paas_v4("http://open.bigmodel.cn/api/paas/v4")
514 .build(false)
515 .is_err()
516 );
517 }
518
519 #[test]
520 fn http_loopback_allowed_when_insecure_enabled() {
521 let ec = EndpointConfig::builder()
522 .paas_v4("http://127.0.0.1:8080/api/paas/v4")
523 .build(true)
524 .unwrap();
525 assert_eq!(ec.base(ApiFamily::PaasV4).scheme(), "http");
526 assert_eq!(ec.base(ApiFamily::PaasV4).host_str(), Some("127.0.0.1"));
527 }
528
529 #[test]
530 fn http_public_host_rejected_even_when_insecure_enabled() {
531 assert!(
533 EndpointConfig::builder()
534 .paas_v4("http://open.bigmodel.cn/api/paas/v4")
535 .build(true)
536 .is_err()
537 );
538 }
539
540 #[test]
541 fn ws_loopback_allowed_for_realtime_when_insecure() {
542 let ec = EndpointConfig::builder()
543 .realtime("ws://localhost:9000/realtime")
544 .build(true)
545 .unwrap();
546 assert_eq!(ec.base(ApiFamily::Realtime).scheme(), "ws");
547 }
548}