1use std::{fmt, sync::Arc};
12
13use chrono::{DateTime, FixedOffset, TimeZone, Utc};
14use serde::{Deserialize, Deserializer, Serialize};
15
16use crate::{
17 ZaiResult,
18 client::{
19 endpoints::{ApiBase, EndpointConfig, paths},
20 http::{HttpClient, HttpClientConfig, parse_typed_response},
21 },
22};
23
24fn de_opt_string_from_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
25where
26 D: Deserializer<'de>,
27{
28 let value = serde_json::Value::deserialize(deserializer)?;
29 match value {
30 serde_json::Value::Null => Ok(None),
31 serde_json::Value::String(value) => Ok(Some(value)),
32 serde_json::Value::Number(value) => Ok(Some(value.to_string())),
33 other => Err(serde::de::Error::custom(format!(
34 "expected string or number, got {other}"
35 ))),
36 }
37}
38
39fn parse_next_reset_time(raw: &str) -> Option<DateTime<Utc>> {
40 if let Ok(timestamp) = raw.parse::<i64>() {
41 return if timestamp.abs() >= 10_000_000_000 {
42 Utc.timestamp_millis_opt(timestamp).single()
43 } else {
44 Utc.timestamp_opt(timestamp, 0).single()
45 };
46 }
47
48 DateTime::parse_from_rfc3339(raw)
49 .ok()
50 .map(|datetime| datetime.with_timezone(&Utc))
51}
52
53fn beijing_offset() -> FixedOffset {
54 FixedOffset::east_opt(8 * 60 * 60).expect("UTC+08:00 is a valid fixed offset")
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct CodingPlanUsageData {
65 #[serde(
67 default,
68 deserialize_with = "de_opt_string_from_string_or_number",
69 skip_serializing_if = "Option::is_none"
70 )]
71 pub level: Option<String>,
72 #[serde(default)]
74 pub limits: Vec<CodingPlanQuotaLimit>,
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct CodingPlanUsageDetail {
80 #[serde(
82 default,
83 alias = "modelCode",
84 deserialize_with = "de_opt_string_from_string_or_number",
85 skip_serializing_if = "Option::is_none"
86 )]
87 pub model_code: Option<String>,
88 #[serde(default)]
90 pub usage: u64,
91}
92
93impl fmt::Display for CodingPlanUsageDetail {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 write!(
96 f,
97 "{}: {}",
98 self.model_code.as_deref().unwrap_or("unknown"),
99 self.usage
100 )
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum CodingPlanQuotaKind {
107 TimeLimit,
109 TokensLimit,
111 Other(String),
113}
114
115impl CodingPlanQuotaKind {
116 pub fn as_str(&self) -> &str {
118 match self {
119 CodingPlanQuotaKind::TimeLimit => "TIME_LIMIT",
120 CodingPlanQuotaKind::TokensLimit => "TOKENS_LIMIT",
121 CodingPlanQuotaKind::Other(type_) => type_,
122 }
123 }
124}
125
126#[derive(Debug, Clone)]
132pub struct CodingPlanQuotaSummary {
133 pub kind: CodingPlanQuotaKind,
135 pub type_: String,
137 pub unit: Option<String>,
139 pub number: u64,
141 pub reported_usage: Option<u64>,
143 pub current_value: Option<u64>,
145 pub reported_remaining: Option<u64>,
147 pub quota: u64,
149 pub used: u64,
151 pub remaining: u64,
153 pub percentage: f64,
155 pub next_reset_time: Option<String>,
157 pub next_reset_at: Option<DateTime<Utc>>,
160 pub usage_details: Vec<CodingPlanUsageDetail>,
162}
163
164fn display_opt<T>(value: Option<T>) -> String
165where
166 T: fmt::Display,
167{
168 value
169 .map(|value| value.to_string())
170 .unwrap_or_else(|| "-".to_string())
171}
172
173impl CodingPlanQuotaSummary {
174 pub fn is_time_limit(&self) -> bool {
176 self.kind == CodingPlanQuotaKind::TimeLimit
177 }
178
179 pub fn is_tokens_limit(&self) -> bool {
181 self.kind == CodingPlanQuotaKind::TokensLimit
182 }
183
184 pub fn used_ratio(&self) -> f64 {
186 if self.quota == 0 {
187 return 0.0;
188 }
189
190 (self.used as f64 / self.quota as f64).clamp(0.0, 1.0)
191 }
192
193 pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
195 self.usage_details
196 .iter()
197 .find(|detail| detail.model_code.as_deref() == Some(model_code))
198 .map(|detail| detail.usage)
199 }
200
201 pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
203 self.next_reset_at
204 .as_ref()
205 .map(|datetime| datetime.with_timezone(&beijing_offset()))
206 }
207}
208
209impl fmt::Display for CodingPlanQuotaSummary {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 writeln!(f, "{}:", self.type_)?;
212 writeln!(f, " kind: {}", self.kind.as_str())?;
213 writeln!(f, " unit: {}", display_opt(self.unit.as_deref()))?;
214 writeln!(f, " number: {}", self.number)?;
215 writeln!(f, " usage: {}", display_opt(self.reported_usage))?;
216 writeln!(f, " current_value: {}", display_opt(self.current_value))?;
217 writeln!(
218 f,
219 " reported_remaining: {}",
220 display_opt(self.reported_remaining)
221 )?;
222 writeln!(f, " quota: {}", self.quota)?;
223 writeln!(f, " used: {}", self.used)?;
224 writeln!(f, " remaining: {}", self.remaining)?;
225 writeln!(f, " percentage: {:.1}%", self.percentage)?;
226 writeln!(
227 f,
228 " next_reset_time: {}",
229 display_opt(self.next_reset_time.as_deref())
230 )?;
231 writeln!(
232 f,
233 " next_reset_at_utc: {}",
234 display_opt(self.next_reset_at.as_ref().map(DateTime::to_rfc3339))
235 )?;
236 writeln!(
237 f,
238 " next_reset_at_beijing: {}",
239 display_opt(
240 self.next_reset_at_beijing()
241 .as_ref()
242 .map(DateTime::to_rfc3339)
243 )
244 )?;
245
246 if self.usage_details.is_empty() {
247 writeln!(f, " usage_details: []")?;
248 } else {
249 writeln!(f, " usage_details:")?;
250 for detail in &self.usage_details {
251 writeln!(f, " - {detail}")?;
252 }
253 }
254
255 Ok(())
256 }
257}
258
259#[derive(Debug, Clone, Default)]
261pub struct CodingPlanUsageSummary {
262 pub code: i64,
264 pub msg: Option<String>,
266 pub success: bool,
268 pub level: Option<String>,
270 pub limits: Vec<CodingPlanQuotaSummary>,
272}
273
274impl CodingPlanUsageSummary {
275 pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaSummary> {
277 self.limits
278 .iter()
279 .find(|limit| limit.type_.eq_ignore_ascii_case(type_))
280 }
281
282 pub fn time_limit(&self) -> Option<&CodingPlanQuotaSummary> {
284 self.limits
285 .iter()
286 .find(|limit| limit.kind == CodingPlanQuotaKind::TimeLimit)
287 }
288
289 pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaSummary> {
291 self.limits
292 .iter()
293 .find(|limit| limit.kind == CodingPlanQuotaKind::TokensLimit)
294 }
295}
296
297impl fmt::Display for CodingPlanUsageSummary {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 writeln!(f, "Coding Plan Usage")?;
300 writeln!(f, "code: {}", self.code)?;
301 writeln!(f, "msg: {}", display_opt(self.msg.as_deref()))?;
302 writeln!(f, "success: {}", self.success)?;
303 writeln!(f, "level: {}", display_opt(self.level.as_deref()))?;
304
305 if self.limits.is_empty() {
306 writeln!(f, "limits: []")?;
307 return Ok(());
308 }
309
310 writeln!(f, "limits:")?;
311 for (index, limit) in self.limits.iter().enumerate() {
312 if index > 0 {
313 writeln!(f)?;
314 }
315 write!(f, "{limit}")?;
316 }
317
318 Ok(())
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct CodingPlanQuotaLimit {
330 #[serde(rename = "type")]
333 pub type_: String,
334 #[serde(
336 default,
337 deserialize_with = "de_opt_string_from_string_or_number",
338 skip_serializing_if = "Option::is_none"
339 )]
340 pub unit: Option<String>,
341 #[serde(default)]
343 pub number: u64,
344 #[serde(default)]
346 pub percentage: f64,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub usage: Option<u64>,
350 #[serde(
352 default,
353 alias = "currentValue",
354 skip_serializing_if = "Option::is_none"
355 )]
356 pub current_value: Option<u64>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub remaining: Option<u64>,
360 #[serde(
362 default,
363 alias = "nextResetTime",
364 deserialize_with = "de_opt_string_from_string_or_number",
365 skip_serializing_if = "Option::is_none"
366 )]
367 pub next_reset_time: Option<String>,
368 #[serde(default, alias = "usageDetails", skip_serializing_if = "Vec::is_empty")]
370 pub usage_details: Vec<CodingPlanUsageDetail>,
371}
372
373impl CodingPlanQuotaLimit {
374 pub fn kind(&self) -> CodingPlanQuotaKind {
376 if self.is_time_limit() {
377 CodingPlanQuotaKind::TimeLimit
378 } else if self.is_tokens_limit() {
379 CodingPlanQuotaKind::TokensLimit
380 } else {
381 CodingPlanQuotaKind::Other(self.type_.clone())
382 }
383 }
384
385 pub fn quota(&self) -> u64 {
387 self.usage.unwrap_or(self.number)
388 }
389
390 pub fn remaining(&self) -> u64 {
395 if let Some(remaining) = self.remaining {
396 return remaining;
397 }
398 if let (Some(usage), Some(current_value)) = (self.usage, self.current_value) {
399 return usage.saturating_sub(current_value);
400 }
401 if self.number == 0 {
402 return 0;
403 }
404 let consumed = (self.number as f64) * (self.percentage / 100.0);
405 self.number.saturating_sub(consumed.round() as u64)
406 }
407
408 pub fn remaining_ratio(&self) -> f64 {
410 if self.number == 0 {
411 return 0.0;
412 }
413 1.0 - (self.percentage / 100.0).clamp(0.0, 1.0)
414 }
415
416 pub fn consumed(&self) -> u64 {
418 if let Some(current_value) = self.current_value {
419 return current_value;
420 }
421 if let (Some(usage), Some(remaining)) = (self.usage, self.remaining) {
422 return usage.saturating_sub(remaining);
423 }
424 let consumed = (self.number as f64) * (self.percentage / 100.0);
425 consumed.round() as u64
426 }
427
428 pub fn used(&self) -> u64 {
430 self.consumed()
431 }
432
433 pub fn next_reset_at(&self) -> Option<DateTime<Utc>> {
436 self.next_reset_time
437 .as_deref()
438 .and_then(parse_next_reset_time)
439 }
440
441 pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
443 self.usage_details
444 .iter()
445 .find(|detail| detail.model_code.as_deref() == Some(model_code))
446 .map(|detail| detail.usage)
447 }
448
449 pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
451 self.next_reset_at()
452 .map(|datetime| datetime.with_timezone(&beijing_offset()))
453 }
454
455 pub fn summary(&self) -> CodingPlanQuotaSummary {
457 CodingPlanQuotaSummary {
458 kind: self.kind(),
459 type_: self.type_.clone(),
460 unit: self.unit.clone(),
461 number: self.number,
462 reported_usage: self.usage,
463 current_value: self.current_value,
464 reported_remaining: self.remaining,
465 quota: self.quota(),
466 used: self.used(),
467 remaining: self.remaining(),
468 percentage: self.percentage,
469 next_reset_time: self.next_reset_time.clone(),
470 next_reset_at: self.next_reset_at(),
471 usage_details: self.usage_details.clone(),
472 }
473 }
474
475 pub fn is_time_limit(&self) -> bool {
477 self.type_.eq_ignore_ascii_case("TIME_LIMIT")
478 }
479
480 pub fn is_tokens_limit(&self) -> bool {
482 self.type_.eq_ignore_ascii_case("TOKENS_LIMIT")
483 }
484}
485
486impl fmt::Display for CodingPlanQuotaLimit {
487 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488 self.summary().fmt(f)
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct CodingPlanUsageResponse {
495 #[serde(default)]
497 pub code: i64,
498 #[serde(
500 default,
501 rename = "msg",
502 deserialize_with = "de_opt_string_from_string_or_number",
503 skip_serializing_if = "Option::is_none"
504 )]
505 pub msg: Option<String>,
506 #[serde(default)]
508 pub success: bool,
509 #[serde(default)]
511 pub data: CodingPlanUsageData,
512}
513
514impl CodingPlanUsageResponse {
515 pub fn limits(&self) -> &[CodingPlanQuotaLimit] {
517 &self.data.limits
518 }
519
520 pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaLimit> {
522 self.data
523 .limits
524 .iter()
525 .find(|l| l.type_.eq_ignore_ascii_case(type_))
526 }
527
528 pub fn time_limit(&self) -> Option<&CodingPlanQuotaLimit> {
530 self.find_limit("TIME_LIMIT")
531 }
532
533 pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaLimit> {
535 self.find_limit("TOKENS_LIMIT")
536 }
537
538 pub fn level(&self) -> Option<&str> {
540 self.data.level.as_deref()
541 }
542
543 pub fn summary(&self) -> CodingPlanUsageSummary {
545 CodingPlanUsageSummary {
546 code: self.code,
547 msg: self.msg.clone(),
548 success: self.success,
549 level: self.data.level.clone(),
550 limits: self
551 .data
552 .limits
553 .iter()
554 .map(CodingPlanQuotaLimit::summary)
555 .collect(),
556 }
557 }
558
559 pub fn time_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
561 self.time_limit().map(CodingPlanQuotaLimit::summary)
562 }
563
564 pub fn tokens_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
566 self.tokens_limit().map(CodingPlanQuotaLimit::summary)
567 }
568}
569
570impl fmt::Display for CodingPlanUsageResponse {
571 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572 self.summary().fmt(f)
573 }
574}
575
576pub struct CodingPlanUsageRequest {
595 pub key: String,
597 url: String,
598 endpoint_config: EndpointConfig,
599 api_base: ApiBase,
600 http_config: Arc<HttpClientConfig>,
601 _body: (),
602}
603
604impl CodingPlanUsageRequest {
605 pub fn new(key: String) -> Self {
607 let endpoint_config = EndpointConfig::default();
608 let api_base = ApiBase::Monitor;
609 let url = endpoint_config.url(&api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
610 Self {
611 key,
612 url,
613 endpoint_config,
614 api_base,
615 http_config: Arc::new(HttpClientConfig::default()),
616 _body: (),
617 }
618 }
619
620 fn rebuild_url(&mut self) {
621 self.url = self
622 .endpoint_config
623 .url(&self.api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
624 }
625
626 pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
629 self.api_base = ApiBase::Custom(base.into());
630 self.rebuild_url();
631 self
632 }
633
634 pub fn with_endpoint_config(mut self, endpoint_config: EndpointConfig) -> Self {
636 self.endpoint_config = endpoint_config;
637 self.rebuild_url();
638 self
639 }
640
641 pub fn with_monitor_base(mut self, base: impl Into<String>) -> Self {
643 self.endpoint_config = self.endpoint_config.with_monitor_base(base);
644 self.rebuild_url();
645 self
646 }
647
648 pub fn with_http_config(mut self, config: HttpClientConfig) -> Self {
650 self.http_config = Arc::new(config);
651 self
652 }
653
654 pub fn url(&self) -> &str {
656 &self.url
657 }
658
659 pub async fn send(&self) -> ZaiResult<CodingPlanUsageResponse> {
661 let resp = self.get().await?;
662 let parsed = parse_typed_response::<CodingPlanUsageResponse>(resp).await?;
663 Ok(parsed)
664 }
665}
666
667impl HttpClient for CodingPlanUsageRequest {
668 type Body = ();
669 type ApiUrl = String;
670 type ApiKey = String;
671
672 fn api_url(&self) -> &Self::ApiUrl {
673 &self.url
674 }
675 fn api_key(&self) -> &Self::ApiKey {
676 &self.key
677 }
678 fn body(&self) -> &Self::Body {
679 &self._body
680 }
681
682 fn http_config(&self) -> Arc<HttpClientConfig> {
683 Arc::clone(&self.http_config)
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn deserializes_typical_envelope() {
693 let raw = r#"{
694 "code": 200,
695 "msg": "ok",
696 "success": true,
697 "data": {
698 "level": "max",
699 "limits": [
700 {
701 "type": "TIME_LIMIT",
702 "unit": "5h",
703 "number": 600,
704 "percentage": 25.0,
705 "nextResetTime": "2026-06-18T15:00:00Z"
706 },
707 {
708 "type": "TOKENS_LIMIT",
709 "unit": "week",
710 "number": 1000000,
711 "percentage": 50.0,
712 "nextResetTime": "2026-06-22T00:00:00Z"
713 }
714 ]
715 }
716 }"#;
717 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
718 assert!(resp.success);
719 assert_eq!(resp.level(), Some("max"));
720 let t = resp.time_limit().unwrap();
721 assert!(t.is_time_limit());
722 assert_eq!(t.consumed(), 150);
723 assert_eq!(t.remaining(), 450);
724 assert!((t.remaining_ratio() - 0.75).abs() < 1e-9);
725 assert_eq!(t.next_reset_time.as_deref(), Some("2026-06-18T15:00:00Z"));
726 assert_eq!(
727 t.next_reset_at().unwrap().to_rfc3339(),
728 "2026-06-18T15:00:00+00:00"
729 );
730 let w = resp.tokens_limit().unwrap();
731 assert!(w.is_tokens_limit());
732 assert_eq!(w.consumed(), 500_000);
733 assert_eq!(w.remaining(), 500_000);
734 }
735
736 #[test]
737 fn deserializes_numeric_plan_level() {
738 let raw = r#"{
739 "code": 200,
740 "msg": "ok",
741 "success": true,
742 "data": {
743 "level": 3,
744 "limits": [
745 {
746 "type": "TIME_LIMIT",
747 "unit": 5,
748 "number": 600,
749 "percentage": 25.0,
750 "usage": 4000,
751 "currentValue": 54,
752 "remaining": 3946,
753 "nextResetTime": 1781778751996,
754 "usageDetails": [
755 {"modelCode": "search-prime", "usage": 40},
756 {"modelCode": "web-reader", "usage": 14}
757 ]
758 }
759 ]
760 }
761 }"#;
762
763 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
764
765 assert_eq!(resp.level(), Some("3"));
766 let limit = resp.time_limit().unwrap();
767 assert_eq!(limit.unit.as_deref(), Some("5"));
768 assert_eq!(limit.quota(), 4000);
769 assert_eq!(limit.consumed(), 54);
770 assert_eq!(limit.remaining(), 3946);
771 assert_eq!(limit.usage_details.len(), 2);
772 assert_eq!(
773 limit.usage_details[0].model_code.as_deref(),
774 Some("search-prime")
775 );
776 assert_eq!(limit.usage_for_model("web-reader"), Some(14));
777 assert_eq!(limit.next_reset_time.as_deref(), Some("1781778751996"));
778
779 let summary = resp.summary();
780 assert_eq!(summary.code, 200);
781 assert_eq!(summary.msg.as_deref(), Some("ok"));
782 assert!(summary.success);
783 assert_eq!(summary.level.as_deref(), Some("3"));
784 let time_limit = summary.time_limit().unwrap();
785 assert_eq!(time_limit.kind, CodingPlanQuotaKind::TimeLimit);
786 assert_eq!(time_limit.number, 600);
787 assert_eq!(time_limit.reported_usage, Some(4000));
788 assert_eq!(time_limit.current_value, Some(54));
789 assert_eq!(time_limit.reported_remaining, Some(3946));
790 assert_eq!(time_limit.quota, 4000);
791 assert_eq!(time_limit.used, 54);
792 assert_eq!(time_limit.remaining, 3946);
793 assert_eq!(time_limit.usage_for_model("search-prime"), Some(40));
794 assert_eq!(
795 time_limit.next_reset_at.as_ref().unwrap().to_rfc3339(),
796 "2026-06-18T10:32:31.996+00:00"
797 );
798 assert_eq!(
799 time_limit
800 .next_reset_at_beijing()
801 .as_ref()
802 .unwrap()
803 .to_rfc3339(),
804 "2026-06-18T18:32:31.996+08:00"
805 );
806
807 let report = summary.to_string();
808 assert!(report.contains("code: 200"));
809 assert!(report.contains("msg: ok"));
810 assert!(report.contains("success: true"));
811 assert!(report.contains("number: 600"));
812 assert!(report.contains("current_value: 54"));
813 assert!(report.contains("reported_remaining: 3946"));
814 assert!(report.contains("next_reset_at_utc: 2026-06-18T10:32:31.996+00:00"));
815 assert!(report.contains("next_reset_at_beijing: 2026-06-18T18:32:31.996+08:00"));
816 assert!(report.contains("usage_details:"));
817 assert!(report.contains("search-prime: 40"));
818 }
819
820 #[test]
821 fn handles_missing_optional_fields() {
822 let raw = r#"{"code":0,"success":true,"data":{"limits":[]}}"#;
823 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
824 assert!(resp.limits().is_empty());
825 assert!(resp.level().is_none());
826 assert!(resp.time_limit().is_none());
827 }
828
829 #[test]
830 fn remaining_is_zero_when_exhausted() {
831 let limit = CodingPlanQuotaLimit {
832 type_: "TIME_LIMIT".to_string(),
833 unit: Some("5h".to_string()),
834 number: 100,
835 percentage: 100.0,
836 usage: None,
837 current_value: None,
838 remaining: None,
839 next_reset_time: None,
840 usage_details: Vec::new(),
841 };
842 assert_eq!(limit.remaining(), 0);
843 assert_eq!(limit.consumed(), 100);
844 }
845
846 #[test]
847 fn request_targets_official_monitor_endpoint() {
848 let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string());
849 assert_eq!(
850 req.url(),
851 "https://open.bigmodel.cn/api/monitor/usage/quota/limit"
852 );
853 }
854
855 #[test]
856 fn request_custom_base_overrides_url() {
857 let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
858 .with_base_url("https://api.z.ai/api/monitor");
859 assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
860 }
861
862 #[test]
863 fn request_monitor_base_overrides_url() {
864 let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
865 .with_monitor_base("https://api.z.ai/api/monitor");
866 assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
867 }
868}