diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..42927d8210 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -89,7 +89,7 @@ OpenCode-held OpenAI/Codex OAuth can be reused for **remote Codex account quota* ### z.ai Coding Plan quotas -z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shortest known Coding Plan window becomes primary and the longest becomes secondary; `TIME_LIMIT` is the separate MCP lane. When absolute usage/remaining counts are available they determine the used percentage, otherwise the provider percentage is used, always clamped to 0–100%. This behavior is shared by the tray, provider detail, CLI, and other Windows surfaces. +z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shortest known Coding Plan window becomes primary and the longest becomes secondary; `TIME_LIMIT` is the separate MCP lane. When absolute usage/remaining counts are available they determine the used percentage, otherwise the provider percentage is used, always clamped to 0–100%. This behavior is shared by the tray, provider detail, CLI, and other Windows surfaces. Empty or wholly unrecognized limits never fabricate a 0% window; they show a "Coding Plan usage: Unavailable" detail row that points to the Usage Dashboard, and mixed responses keep the recognized windows and add "Additional quota: Unavailable". Unknown string limit types are skipped, while malformed entries and unsupported envelopes fail with Usage Dashboard guidance. Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces. diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 44d71c19da..6d423d3559 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -26,8 +26,8 @@ use reqwest::Url; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; use reset_plausibility::is_plausible_five_hour_reset; @@ -49,15 +49,16 @@ struct ZaiQuotaResponse { message: Option, #[serde(default)] data: Option, - /// Legacy flat limits array (backwards compat) + /// Legacy flat limits array (backwards compat). Entries stay raw so an + /// unknown limit type is skipped without requiring the legacy fields. #[serde(default)] - limits: Vec, + limits: Option>, } #[derive(Debug, Deserialize)] struct ZaiQuotaData { #[serde(default)] - limits: Vec, + limits: Option>, #[serde(rename = "planName")] plan_name: Option, /// Upstream plan-name fallbacks (`level` added in 0.48.0). @@ -101,6 +102,98 @@ struct ZaiLimit { next_reset_time: Option, } +impl ZaiLimit { + /// Whether the entry carries any usage figure to derive a percentage from. + fn has_quota_signal(&self) -> bool { + [ + self.used, + self.usage, + self.current_value, + self.limit, + self.remaining, + self.percentage, + ] + .iter() + .any(Option::is_some) + } +} + +const ZAI_UNSUPPORTED_FORMAT: &str = + "Unsupported z.ai quota format. Check Usage Dashboard for plan usage."; +const ZAI_UNSUPPORTED_ENTRY: &str = + "Unsupported z.ai quota entry. Check Usage Dashboard for plan usage."; +const ZAI_UNAVAILABLE_HINT: &str = "Check Usage Dashboard for complete plan usage."; + +/// Parsed quota plus the display detail explaining any unavailable quota. +#[derive(Debug)] +struct ZaiParsedQuota { + usage: UsageSnapshot, + unavailable_detail: Option, +} + +fn is_token_limit_type(limit_type: Option<&str>) -> bool { + matches!( + limit_type, + Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") + ) +} + +fn is_time_limit_type(limit_type: Option<&str>) -> bool { + matches!(limit_type, Some("TIME_LIMIT") | Some("mcp")) +} + +/// Split raw limit entries into recognized limits and a skipped-entry count. +/// +/// Upstream 0.69.0 (#4091): a string `type` outside the known limit types is +/// skipped without needing legacy fields; a missing or non-string `type`, a +/// recognized entry that does not deserialize, or a recognized entry with no +/// quota signal at all (which would fabricate a 0% window) is a malformed entry. +fn recognized_limits(raw: &[serde_json::Value]) -> Result<(Vec, usize), ProviderError> { + let unsupported_entry = || ProviderError::Parse(ZAI_UNSUPPORTED_ENTRY.to_string()); + let mut limits = Vec::with_capacity(raw.len()); + let mut skipped = 0; + for entry in raw { + let Some(limit_type) = entry.get("type").and_then(serde_json::Value::as_str) else { + return Err(unsupported_entry()); + }; + if !is_token_limit_type(Some(limit_type)) && !is_time_limit_type(Some(limit_type)) { + skipped += 1; + continue; + } + let limit = ZaiLimit::deserialize(entry).map_err(|_| unsupported_entry())?; + if !limit.has_quota_signal() { + return Err(unsupported_entry()); + } + limits.push(limit); + } + Ok((limits, skipped)) +} + +/// Decode the quota envelope. A well-formed JSON body of the wrong shape is an +/// unsupported format (points at the Usage Dashboard); a syntax error keeps +/// the parser message. +fn parse_quota_body(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|e| { + if e.classify() == serde_json::error::Category::Data { + ProviderError::Parse(ZAI_UNSUPPORTED_FORMAT.to_string()) + } else { + ProviderError::Parse(e.to_string()) + } + }) +} + +/// Detail row for quota the API returned but this client cannot show. The +/// title mirrors upstream: "Additional quota" when recognized token windows +/// exist, otherwise "Coding Plan usage". +fn unavailable_quota_detail(has_token_limits: bool) -> Option { + let (id, title) = if has_token_limits { + ("additional-quota", "Additional quota") + } else { + ("coding-plan-usage", "Coding Plan usage") + }; + ProviderDisplayDetail::new(id, title, "Unavailable")?.with_secondary_value(ZAI_UNAVAILABLE_HINT) +} + /// z.ai provider pub struct ZaiProvider { metadata: ProviderMetadata, @@ -236,7 +329,10 @@ impl ZaiProvider { } /// Fetch usage from z.ai API - async fn fetch_usage_api(&self, ctx: &FetchContext) -> Result { + async fn fetch_usage_api( + &self, + ctx: &FetchContext, + ) -> Result { let env = settings::process_env(); let region = Self::effective_region(ctx, &env); // Canonical cross-region overrides are rejected before any bearer @@ -286,10 +382,12 @@ impl ZaiProvider { )); } - let quota: ZaiQuotaResponse = - serde_json::from_slice(&resp_bytes).map_err(|e| ProviderError::Parse(e.to_string()))?; + let quota = parse_quota_body(&resp_bytes)?; - let mut usage = self.parse_quota_response("a)?; + let ZaiParsedQuota { + mut usage, + unavailable_detail, + } = self.parse_quota_response("a)?; if region == ZaiRegion::BigModelCn && let Some(balance) = balance::fetch_cn_balance(&client, &authorization).await { @@ -297,13 +395,13 @@ impl ZaiProvider { row.reset_description = Some(format!("¥{balance:.2} available")); usage = usage.with_extra_rate_window("zai-account-balance", "Account balance", row); } - Ok(usage) + Ok(ProviderFetchResult::new(usage, "oauth").with_display_detail(unavailable_detail)) } fn parse_quota_response( &self, quota: &ZaiQuotaResponse, - ) -> Result { + ) -> Result { if quota.code.is_some_and(|code| code != 0 && code != 200) { return Err(ProviderError::Other( quota @@ -316,11 +414,12 @@ impl ZaiProvider { } // Get limits from data.limits (upstream) or flat limits (legacy) - let limits = if let Some(data) = "a.data { - &data.limits - } else { - "a.limits - }; + let raw_limits = match "a.data { + Some(data) => data.limits.as_deref(), + None => quota.limits.as_deref(), + } + .ok_or_else(|| ProviderError::Parse(ZAI_UNSUPPORTED_FORMAT.to_string()))?; + let (limits, skipped_limits) = recognized_limits(raw_limits)?; // Upstream 0.48.0 plan-name fallbacks: planName, plan, plan_type, // packageName, level — first non-empty trimmed wins. let plan_name = quota @@ -343,14 +442,8 @@ impl ZaiProvider { // Collect token/credit limit entries (upstream 0.49.0 #2724: credit // Coding Plans report `CREDIT_LIMIT` rows with the same shape as // `TOKENS_LIMIT`; upstream uses "TOKENS_LIMIT", legacy uses "tokens"). - let is_tokens = |l: &&ZaiLimit| { - matches!( - l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") - ) - }; - let is_time = - |l: &&ZaiLimit| matches!(l.limit_type.as_deref(), Some("TIME_LIMIT") | Some("mcp")); + let is_tokens = |l: &&ZaiLimit| is_token_limit_type(l.limit_type.as_deref()); + let is_time = |l: &&ZaiLimit| is_time_limit_type(l.limit_type.as_deref()); let mut token_limits: Vec<&ZaiLimit> = limits.iter().filter(is_tokens).collect(); // Upstream ordering: ascending window minutes, unknown windows last. token_limits.sort_by_key(|l| Self::window_minutes(l).unwrap_or(u32::MAX)); @@ -402,11 +495,7 @@ impl ZaiProvider { // "5-hour"; otherwise the explicit window label is used. let now = Utc::now(); let make_window = |l: &ZaiLimit| -> RateWindow { - let is_tokens = matches!( - l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") - ); - let window_mins = if is_tokens { + let window_mins = if is_token_limit_type(l.limit_type.as_deref()) { ZaiProvider::window_minutes(l) } else { None @@ -446,9 +535,11 @@ impl ZaiProvider { None }; + // No recognized limit: never fabricate a 0% quota window. The detail + // row below explains that plan usage is unavailable. let primary = primary_limit .map(make_window) - .unwrap_or_else(|| RateWindow::new(0.0)); + .unwrap_or_else(|| RateWindow::informational("Unavailable")); let mut usage = UsageSnapshot::new(primary).with_login_method(plan_name); if let Some(secondary) = secondary_limit { usage = usage.with_secondary(make_window(secondary)); @@ -461,7 +552,14 @@ impl ZaiProvider { usage = usage.with_extra_rate_window("zai-mcp", "MCP", make_window(mcp)); } - Ok(usage) + let unavailable_detail = (limits.is_empty() || skipped_limits > 0) + .then(|| unavailable_quota_detail(!token_limits.is_empty())) + .flatten(); + + Ok(ZaiParsedQuota { + usage, + unavailable_detail, + }) } /// Compute window_minutes from a limit's unit + number fields. @@ -483,14 +581,10 @@ impl ZaiProvider { /// Upstream 0.48.0 `resetDescription`: MCP (TIME_LIMIT) → "MCP"; 5-hour /// token window → "5-hour"; else the explicit window label, if any. fn rate_window_reset_description(l: &ZaiLimit, window_mins: Option) -> Option { - if matches!(l.limit_type.as_deref(), Some("TIME_LIMIT") | Some("mcp")) { + if is_time_limit_type(l.limit_type.as_deref()) { return Some("MCP".to_string()); } - if matches!( - l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") - ) && window_mins == Some(300) - { + if is_token_limit_type(l.limit_type.as_deref()) && window_mins == Some(300) { return Some("5-hour".to_string()); } window_label(l) @@ -593,10 +687,7 @@ impl Provider for ZaiProvider { // z.ai only supports OAuth/API token - no CLI or web cookie fallback match ctx.source_mode { - SourceMode::Auto | SourceMode::OAuth => { - let usage = self.fetch_usage_api(ctx).await?; - Ok(ProviderFetchResult::new(usage, "oauth")) - } + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, SourceMode::Web | SourceMode::Cli => { // z.ai doesn't support web cookies or CLI Err(ProviderError::UnsupportedSource(ctx.source_mode)) diff --git a/rust/src/providers/zai/tests.rs b/rust/src/providers/zai/tests.rs index 12689811a3..b02ecd2781 100644 --- a/rust/src/providers/zai/tests.rs +++ b/rust/src/providers/zai/tests.rs @@ -81,7 +81,7 @@ fn parses_successful_response_without_message() { })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.login_method.as_deref(), Some("BigModel CN")); assert_eq!(usage.primary.used_percent, 10.0); @@ -107,7 +107,7 @@ fn parses_current_api_percentage_and_reset_time() { })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.primary.used_percent, 75.0); assert_eq!(usage.primary.window_minutes, Some(300)); @@ -145,7 +145,10 @@ fn five_hour_reset_plausibility_drops_impossible_timestamp() { })) .unwrap(); - let usage = ZaiProvider::new().parse_quota_response("a).unwrap(); + let usage = ZaiProvider::new() + .parse_quota_response("a) + .unwrap() + .usage; assert_eq!(usage.primary.used_percent, 25.0); assert_eq!(usage.primary.window_minutes, Some(300)); assert_eq!(usage.primary.reset_description.as_deref(), Some("5-hour")); @@ -210,7 +213,7 @@ fn credit_limit_plan_drives_primary_and_weekly_windows() { })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; // Shortest window (5h credits) is the primary; longest (weekly) secondary. assert!((usage.primary.used_percent - 95.0).abs() < f64::EPSILON); @@ -244,7 +247,7 @@ fn usage_signal_overrides_stale_percentage() { })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert!((usage.primary.used_percent - 5.0).abs() < f64::EPSILON); } @@ -270,7 +273,7 @@ fn time_limit_primary_carries_mcp_label_without_duration() { } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.primary.window_minutes, None); assert_eq!(usage.primary.reset_description.as_deref(), Some("MCP")); assert!(usage.primary.resets_at.is_some()); @@ -295,7 +298,7 @@ fn bare_time_limit_primary_has_no_window_duration() { } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.primary.window_minutes, None); assert_eq!(usage.primary.reset_description.as_deref(), Some("MCP")); } @@ -326,7 +329,7 @@ fn mcp_limit_renders_separate_named_window() { } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.primary.window_minutes, Some(10080)); assert_eq!( @@ -371,7 +374,7 @@ fn session_five_hour_window_becomes_primary_over_weekly() { } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.primary.used_percent, 55.0); assert_eq!(usage.primary.window_minutes, Some(300)); @@ -394,7 +397,7 @@ fn plan_name_falls_back_to_level_key() { } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.login_method.as_deref(), Some("GLM Coding Plan")); for key in ["plan", "plan_type", "packageName"] { @@ -403,7 +406,7 @@ fn plan_name_falls_back_to_level_key() { "data": { key: "Coding Plan", "limits": [] } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.login_method.as_deref(), Some("Coding Plan"), "{key}"); } } @@ -416,7 +419,7 @@ fn empty_plan_fields_fall_back_to_default() { "data": { "planName": " ", "level": "", "limits": [] } })) .unwrap(); - let usage = provider.parse_quota_response("a).unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; assert_eq!(usage.login_method.as_deref(), Some("z.ai")); } @@ -433,3 +436,141 @@ fn preserves_api_code_error_message() { assert!(error.to_string().contains("invalid token")); } + +fn parse_data(data: serde_json::Value) -> Result { + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": data, + })) + .unwrap(); + ZaiProvider::new().parse_quota_response("a) +} + +fn detail_value(parsed: &ZaiParsedQuota) -> Option<(&str, &str, Option<&str>)> { + parsed + .unavailable_detail + .as_ref() + .map(|row| (row.title(), row.value(), row.secondary_value())) +} + +#[test] +fn missing_or_unrecognized_limits_do_not_fabricate_quota() { + for limits in [ + serde_json::json!([]), + serde_json::json!([{"type": "FUTURE_LIMIT", "unit": 3, "number": 5, "percentage": 40}]), + serde_json::json!([{"type": "FUTURE_POINTS_POOL", "pointsRemaining": 800}]), + ] { + let parsed = parse_data(serde_json::json!({"level": "Pro", "limits": limits})).unwrap(); + assert!(parsed.usage.primary.is_informational); + assert!(parsed.usage.secondary.is_none()); + assert!(parsed.usage.extra_rate_windows.is_empty()); + assert_eq!(parsed.usage.login_method.as_deref(), Some("Pro")); + assert_eq!( + detail_value(&parsed), + Some(( + "Coding Plan usage", + "Unavailable", + Some("Check Usage Dashboard for complete plan usage.") + )) + ); + } +} + +#[test] +fn unsupported_pool_preserves_known_mcp_limit() { + let parsed = parse_data(serde_json::json!({"limits": [ + {"type": "FUTURE_POINTS_POOL", "pointsRemaining": 800}, + {"type": "TIME_LIMIT", "unit": 5, "number": 1, "percentage": 25} + ]})) + .unwrap(); + + assert_eq!( + parsed.usage.primary.reset_description.as_deref(), + Some("MCP") + ); + assert_eq!(parsed.usage.primary.used_percent, 25.0); + assert!(!parsed.usage.primary.is_informational); + assert_eq!( + detail_value(&parsed).map(|row| (row.0, row.1)), + Some(("Coding Plan usage", "Unavailable")) + ); +} + +#[test] +fn unknown_extra_limit_marks_only_additional_quota_unavailable() { + let parsed = parse_data(serde_json::json!({"limits": [ + {"type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 25}, + {"type": "FUTURE_POINTS_POOL", "pointsRemaining": 800} + ]})) + .unwrap(); + + assert_eq!(parsed.usage.primary.used_percent, 25.0); + assert_eq!( + detail_value(&parsed).map(|row| (row.0, row.1)), + Some(("Additional quota", "Unavailable")) + ); +} + +#[test] +fn fully_recognized_limits_have_no_unavailable_detail() { + let parsed = parse_data(serde_json::json!({"limits": [ + {"type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 0} + ]})) + .unwrap(); + + assert_eq!(parsed.usage.primary.used_percent, 0.0); + assert!(parsed.unavailable_detail.is_none()); +} + +#[test] +fn malformed_entries_and_envelopes_point_to_usage_dashboard() { + for data in [ + serde_json::json!({"pointsPool": {"remaining": 800}}), + serde_json::json!({"limits": null}), + serde_json::json!({"limits": [{"unit": 3, "number": 5, "percentage": 25}]}), + serde_json::json!({"limits": [{"type": null, "unit": 3, "number": 5, "percentage": 25}]}), + serde_json::json!({"limits": [{"type": 42, "unit": 3, "number": 5, "percentage": 25}]}), + serde_json::json!({"limits": ["TOKENS_LIMIT"]}), + serde_json::json!({"limits": [{"type": "TOKENS_LIMIT", "percentage": "high"}]}), + ] { + let error = parse_data(data.clone()).expect_err("unsupported shape must fail"); + assert!( + error + .to_string() + .contains("Check Usage Dashboard for plan usage."), + "{data}: {error}" + ); + } +} + +#[test] +fn quota_body_maps_shape_errors_to_dashboard_guidance_but_keeps_syntax_errors() { + let shape = parse_quota_body(br#"{"code":200,"data":{"limits":{}}}"#) + .expect_err("object limits must not deserialize"); + assert!( + shape + .to_string() + .contains("Unsupported z.ai quota format. Check Usage Dashboard for plan usage."), + "{shape}" + ); + + let syntax = parse_quota_body(b"{not json").expect_err("syntax error"); + assert!(!syntax.to_string().contains("Usage Dashboard"), "{syntax}"); +} + +#[test] +fn recognized_entry_without_any_quota_signal_is_malformed() { + for entry in [ + serde_json::json!({"type": "TOKENS_LIMIT"}), + serde_json::json!({"type": "TIME_LIMIT", "unit": 5, "number": 1}), + ] { + let error = parse_data(serde_json::json!({"limits": [entry]})) + .expect_err("signal-less entry must not fabricate 0%"); + assert!( + error + .to_string() + .contains("Unsupported z.ai quota entry. Check Usage Dashboard for plan usage."), + "{error}" + ); + } +}