From 9e8e0df575561e187c71e7f8938d950744e04daf Mon Sep 17 00:00:00 2001 From: dongjimin Date: Fri, 7 Aug 2026 10:59:48 +0800 Subject: [PATCH 1/2] feat(providers): add CodeBuddy CN credit usage provider Add a cookie-backed CodeBuddy CN provider that reads remaining credits from get-user-resource, with fallbacks for ~/.codebuddy/cb_cookie.txt and the local cb_credits.json cache. Wire ProviderId, factory, token accounts, and tray/settings catalog entries. --- .../src/commands/provider_settings.rs | 1 + .../src/components/providers/providerIcons.ts | 1 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/core/provider.rs | 11 +- rust/src/core/provider_factory.rs | 4 +- rust/src/core/token_accounts.rs | 8 + rust/src/providers/codebuddy/README.md | 83 +++ rust/src/providers/codebuddy/mod.rs | 538 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + 10 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 rust/src/providers/codebuddy/README.md create mode 100644 rust/src/providers/codebuddy/mod.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 6b237d4325..04eed75afd 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -83,6 +83,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Ollama, "mistral" => ProviderId::Mistral, "qoder" => ProviderId::Qoder, + "codebuddy" => ProviderId::CodeBuddy, "sakana" => ProviderId::Sakana, "notion" => ProviderId::Notion, _ => return None, diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index ea2cd8db55..a8e063b50a 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -202,6 +202,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof }, crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel }, qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder }, + codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" }, sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana }, stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun }, sub2api: { id: "sub2api", brandColor: "#2dc6d8", fallbackLetter: "S", svgPath: RAW.sub2api }, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index eaabe525cf..83c0675070 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -22,7 +22,7 @@ const HAS_DASHBOARD = new Set([ "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", - "opencode", "opencodego", "openrouter", "perplexity", "qoder", "sakana", "stepfun", + "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", "xai", "zai", ]); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 595c31ce95..9197dd355c 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -61,6 +61,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["zed", "Zed"], ["crossmodel", "CrossModel (removed)"], ["qoder", "Qoder"], + ["codebuddy", "CodeBuddy"], ["sakana", "Sakana AI"], ["sub2api", "sub2api"], ["qwencloud", "Qwen Cloud"], diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 70336416f4..475bf3ae61 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -68,6 +68,7 @@ pub enum ProviderId { Zed, CrossModel, Qoder, + CodeBuddy, Sakana, Sub2Api, Wayfinder, @@ -142,6 +143,7 @@ impl ProviderId { ProviderId::Zed, ProviderId::CrossModel, ProviderId::Qoder, + ProviderId::CodeBuddy, ProviderId::Sakana, ProviderId::Sub2Api, ProviderId::Wayfinder, @@ -216,6 +218,7 @@ impl ProviderId { ProviderId::Zed => "zed", ProviderId::CrossModel => "crossmodel", ProviderId::Qoder => "qoder", + ProviderId::CodeBuddy => "codebuddy", ProviderId::Sakana => "sakana", ProviderId::Sub2Api => "sub2api", ProviderId::Wayfinder => "wayfinder", @@ -292,6 +295,7 @@ impl ProviderId { // Soft-removed (upstream #2254); still resolvable via CLI for legacy configs. ProviderId::CrossModel => "CrossModel (removed)", ProviderId::Qoder => "Qoder", + ProviderId::CodeBuddy => "CodeBuddy", ProviderId::Sakana => "Sakana AI", ProviderId::Sub2Api => "sub2api", ProviderId::Wayfinder => "Wayfinder", @@ -339,6 +343,7 @@ impl ProviderId { ProviderId::CommandCode => Some("commandcode.ai"), ProviderId::Grok => Some("grok.com"), ProviderId::Qoder => Some("qoder.com"), + ProviderId::CodeBuddy => Some("codebuddy.cn"), ProviderId::Sakana => Some("console.sakana.ai"), ProviderId::LongCat => Some("longcat.chat"), // Token-based providers (don't use cookies) @@ -457,6 +462,9 @@ impl ProviderId { Some(ProviderId::CrossModel) } "qoder" => Some(ProviderId::Qoder), + "codebuddy" | "code-buddy" | "codebuddy-cn" | "codebuddycn" | "腾讯codebuddy" => { + Some(ProviderId::CodeBuddy) + } "sakana" | "sakana-ai" | "sakana ai" => Some(ProviderId::Sakana), "sub2api" | "sub-2-api" | "sub 2 api" => Some(ProviderId::Sub2Api), "wayfinder" => Some(ProviderId::Wayfinder), @@ -724,7 +732,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 68); + assert_eq!(all.len(), 69); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Kimi)); @@ -762,6 +770,7 @@ mod tests { assert!(all.contains(&ProviderId::Zed)); assert!(all.contains(&ProviderId::CrossModel)); assert!(all.contains(&ProviderId::Qoder)); + assert!(all.contains(&ProviderId::CodeBuddy)); assert!(all.contains(&ProviderId::Sakana)); assert!(all.contains(&ProviderId::Sub2Api)); assert!(all.contains(&ProviderId::Wayfinder)); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 2ac2e5e8f5..e5e99e1a41 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -17,7 +17,8 @@ use crate::providers::{ LongCatProvider, ManusProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, - QoderProvider, QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, + CodeBuddyProvider, QoderProvider, QwenCloudProvider, SakanaProvider, StepFunProvider, + Sub2ApiProvider, T3ChatProvider, VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; @@ -85,6 +86,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Zed => Box::new(ZedProvider::new()), ProviderId::CrossModel => Box::new(CrossModelProvider::new()), ProviderId::Qoder => Box::new(QoderProvider::new()), + ProviderId::CodeBuddy => Box::new(CodeBuddyProvider::new()), ProviderId::Sakana => Box::new(SakanaProvider::new()), ProviderId::Sub2Api => Box::new(Sub2ApiProvider::new()), ProviderId::Wayfinder => Box::new(WayfinderProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 8a71cfef02..72cbe2fc59 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -189,6 +189,14 @@ impl TokenAccountSupport { requires_manual_cookie_source: true, cookie_name: None, }), + ProviderId::CodeBuddy => Some(TokenAccountSupport { + title: "Session tokens", + subtitle: "Store CodeBuddy CN Cookie headers (from plans-usage DevTools cURL).", + placeholder: "Cookie: session=...; ... (or paste full Cookie header)", + injection: TokenInjection::CookieHeader, + requires_manual_cookie_source: true, + cookie_name: None, + }), ProviderId::Sakana => Some(TokenAccountSupport { title: "Session tokens", subtitle: "Store multiple Sakana Console Cookie headers.", diff --git a/rust/src/providers/codebuddy/README.md b/rust/src/providers/codebuddy/README.md new file mode 100644 index 0000000000..da2f2c7ebb --- /dev/null +++ b/rust/src/providers/codebuddy/README.md @@ -0,0 +1,83 @@ +# CodeBuddy (soft-fork) + +Tencent **CodeBuddy CN** credit usage for Win-CodexBar. + +## What it shows + +- Primary meter: **Credits** used % across all returned resource packages +- Description: `remaining/total` plus package count +- Reset time: earliest package expire time when present + +## Setup + +1. Settings → Providers → enable **CodeBuddy** +2. Prefer **manual Cookie** (Chrome 127+ App-Bound Encryption often blocks auto-import): + + - Open + - DevTools → Network → `get-user-resource` → Copy as cURL + - Paste the `Cookie:` value into CodeBuddy settings / token account + +3. Or put the cookie on one line in: + + ``` + %USERPROFILE%\.codebuddy\cb_cookie.txt + ``` + + (compatible with `D:\workspace\codebuddy-statusline`) + +## API + +```http +POST https://www.codebuddy.cn/billing/meter/get-user-resource +Origin: https://www.codebuddy.cn +Referer: https://www.codebuddy.cn/profile/plans-usage +User-Agent: Chrome/* (must NOT contain Edg/) +Cookie: … +Content-Type: application/json + +{ + "PageNumber": 1, + "PageSize": 200, + "ProductCode": "p_tcaca", + "Status": [0, 3], + "OnlyValidPeriod": true, + "PackageCodes": [ "TCACA_code_007_…", … ] +} +``` + +Sums `CapacitySize* / CapacityUsed* / CapacityRemain*` over `data.Response.Data.Accounts`. + +### EdgeOne 401 + +Tencent EdgeOne rejects Microsoft Edge user-agents on this path. The provider always sends a Chrome UA without `Edg/`. + +Expired cookies also surface as 401 — refresh `cb_cookie.txt` / manual cookie. + +### Empty Accounts + +If `Accounts` is `[]`, your package codes differ. Copy `PackageCodes` from the browser request body and set: + +```powershell +$env:CB_PACKAGE_CODES = '["TCACA_code_007_...","TCACA_code_029_..."]' +``` + +## Local cache fallback + +Source mode **Auto** / **CLI** can read: + +``` +%USERPROFILE%\.codebuddy\cb_credits.json +``` + +produced by `codebuddy-statusline`’s `parse_credits.js` pipeline. + +## CLI + +```text +codexbar-cli usage -p codebuddy --verbose +``` + +## Not covered yet + +- International `codebuddy.ai` (different host / product codes) +- OAuth device flow (cookie session only) diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs new file mode 100644 index 0000000000..9ce7746678 --- /dev/null +++ b/rust/src/providers/codebuddy/mod.rs @@ -0,0 +1,538 @@ +//! CodeBuddy (Tencent) provider — soft-fork addition. +//! +//! Fetches account credit packages from the China billing API: +//! `POST https://www.codebuddy.cn/billing/meter/get-user-resource` +//! +//! Auth sources (priority): +//! 1. Manual Cookie header (Settings / token accounts) +//! 2. `~/.codebuddy/cb_cookie.txt` (same file as codebuddy-statusline) +//! 3. Browser cookies for `codebuddy.cn` / `www.codebuddy.cn` +//! +//! Optional CLI fallback: read normalized `~/.codebuddy/cb_credits.json` +//! when web fetch is unavailable. +//! +//! EdgeOne note: UA must look like Chrome without `Edg/` or the WAF returns 401. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::path::PathBuf; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; + +const CN_API: &str = "https://www.codebuddy.cn/billing/meter/get-user-resource"; +const CN_ORIGIN: &str = "https://www.codebuddy.cn"; +const CN_REFERER: &str = "https://www.codebuddy.cn/profile/plans-usage"; +const PRODUCT_CODE: &str = "p_tcaca"; +/// Chrome UA without Edg/ — Edge UA is rejected by Tencent EdgeOne on this path. +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; + +/// Default package codes covering main credits + personal subscription packs. +/// Override via env `CB_PACKAGE_CODES` (JSON array string) when the account differs. +const DEFAULT_PACKAGE_CODES: &[&str] = &[ + "TCACA_code_007_nzdH5h4Nl0", + "TCACA_code_029_6wCGEWquYy", + "TCACA_code_030_BjSt89qTvr", + "TCACA_code_008_cfWoLwvjU4", + "TCACA_code_002_AkiJS3ZHF5", + "TCACA_code_023_4xbGhMrE6q", + "TCACA_code_026_BaESVICNoi", + "TCACA_code_027_0FCGVA6vSa", +]; + +pub struct CodeBuddyProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl CodeBuddyProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::CodeBuddy, + display_name: "CodeBuddy", + session_label: "Credits", + weekly_label: "Packages", + supports_opus: false, + supports_credits: true, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://www.codebuddy.cn/profile/plans-usage"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + async fn fetch_web(&self, cookie_header: &str) -> Result { + let cookie = normalize_cookie_header(cookie_header).ok_or(ProviderError::NoCookies)?; + let body = request_body(); + let response = self + .client + .post(CN_API) + .header("Cookie", &cookie) + .header("Accept", "application/json, text/plain, */*") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7") + .header("Content-Type", "application/json") + .header("Origin", CN_ORIGIN) + .header("Referer", CN_REFERER) + .header("User-Agent", USER_AGENT) + .header("x-client-platform", "web") + .header("sec-fetch-dest", "empty") + .header("sec-fetch-mode", "cors") + .header("sec-fetch-site", "same-origin") + .json(&body) + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "CodeBuddy get-user-resource returned HTTP {status}" + ))); + } + + let value = response + .json::() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse CodeBuddy response: {e}")))?; + + let snapshot = snapshot_from_api_payload(&value)?; + Ok(ProviderFetchResult::new(snapshot, "web")) + } + + fn fetch_local_cache(&self) -> Result { + let path = credits_cache_path().ok_or_else(|| { + ProviderError::Other("Could not resolve ~/.codebuddy/cb_credits.json".into()) + })?; + let raw = std::fs::read_to_string(&path).map_err(|_| { + ProviderError::Other(format!( + "No CodeBuddy cookie and no local cache at {}", + path.display() + )) + })?; + let value: Value = serde_json::from_str(&raw) + .map_err(|e| ProviderError::Parse(format!("Invalid cb_credits.json: {e}")))?; + let snapshot = snapshot_from_cache_payload(&value)?; + Ok(ProviderFetchResult::new(snapshot, "cli")) + } + + fn resolve_cookie(&self, ctx: &FetchContext) -> Result { + if let Some(cookie) = ctx.manual_cookie_header.as_deref() { + if let Some(normalized) = normalize_cookie_header(cookie) { + return Ok(normalized); + } + } + if let Some(from_file) = read_cookie_file() { + return Ok(from_file); + } + let header = crate::providers::browser_cookie_header(&[ + "codebuddy.cn", + "www.codebuddy.cn", + ])?; + normalize_cookie_header(&header).ok_or(ProviderError::NoCookies) + } +} + +impl Default for CodeBuddyProvider { + fn default() -> Self { + Self::new() + } +} + +fn request_body() -> Value { + let codes: Vec = package_codes(); + json!({ + "PageNumber": 1, + "PageSize": 200, + "ProductCode": PRODUCT_CODE, + "Status": [0, 3], + "OnlyValidPeriod": true, + "PackageCodes": codes, + }) +} + +fn package_codes() -> Vec { + if let Ok(raw) = std::env::var("CB_PACKAGE_CODES") { + if let Ok(Value::Array(items)) = serde_json::from_str::(&raw) { + let parsed: Vec = items + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| !s.is_empty()) + .collect(); + if !parsed.is_empty() { + return parsed; + } + } + } + DEFAULT_PACKAGE_CODES + .iter() + .map(|s| (*s).to_string()) + .collect() +} + +fn codebuddy_home() -> Option { + if let Ok(home) = std::env::var("CODEBUDDY_HOME") { + let p = PathBuf::from(home); + if !p.as_os_str().is_empty() { + return Some(p); + } + } + dirs::home_dir().map(|h| h.join(".codebuddy")) +} + +fn cookie_file_path() -> Option { + if let Ok(p) = std::env::var("CB_COOKIE_FILE") { + let path = PathBuf::from(p); + if !path.as_os_str().is_empty() { + return Some(path); + } + } + codebuddy_home().map(|h| h.join("cb_cookie.txt")) +} + +fn credits_cache_path() -> Option { + if let Ok(p) = std::env::var("CB_CREDITS_FILE") { + let path = PathBuf::from(p); + if !path.as_os_str().is_empty() { + return Some(path); + } + } + codebuddy_home().map(|h| h.join("cb_credits.json")) +} + +fn read_cookie_file() -> Option { + let path = cookie_file_path()?; + let raw = std::fs::read_to_string(path).ok()?; + normalize_cookie_header(&raw) +} + +fn normalize_cookie_header(raw: &str) -> Option { + // Strip BOM, caret-escapes from Windows "Copy as cURL", and Cookie: prefix. + let mut header = raw.trim().trim_start_matches('\u{feff}').replace('^', ""); + header = header.trim().to_string(); + let lower = header.to_ascii_lowercase(); + if lower.starts_with("cookie:") { + header = header["cookie:".len()..].trim().to_string(); + } + let pairs = header + .split(';') + .filter_map(|chunk| { + let (name, value) = chunk.trim().split_once('=')?; + let name = name.trim(); + let value = value.trim(); + (!name.is_empty() && !value.is_empty()).then(|| format!("{name}={value}")) + }) + .collect::>(); + (!pairs.is_empty()).then(|| pairs.join("; ")) +} + +fn snapshot_from_api_payload(value: &Value) -> Result { + let code = value.get("code").and_then(|c| c.as_i64()).unwrap_or(-1); + if code != 0 { + let msg = value + .get("msg") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + if msg.to_ascii_lowercase().contains("auth") + || msg.contains("登录") + || msg.contains("未登录") + { + return Err(ProviderError::AuthRequired); + } + return Err(ProviderError::Other(format!( + "CodeBuddy API code={code}: {msg}" + ))); + } + + let accounts = value + .pointer("/data/Response/Data/Accounts") + .and_then(|a| a.as_array()) + .ok_or_else(|| { + ProviderError::Parse( + "CodeBuddy response missing data.Response.Data.Accounts (check PackageCodes)" + .into(), + ) + })?; + + if accounts.is_empty() { + return Err(ProviderError::Parse( + "CodeBuddy returned zero packages — set CB_PACKAGE_CODES from browser cURL body".into(), + )); + } + + let mut total = 0.0_f64; + let mut used = 0.0_f64; + let mut remaining = 0.0_f64; + let mut earliest_reset: Option> = None; + + for account in accounts { + total += precise_or_field(account, "CapacitySizePrecise", "CapacitySize").unwrap_or(0.0); + used += precise_or_field(account, "CapacityUsedPrecise", "CapacityUsed").unwrap_or(0.0); + remaining += + precise_or_field(account, "CapacityRemainPrecise", "CapacityRemain").unwrap_or(0.0); + if let Some(reset) = expire_time(account) { + earliest_reset = Some(match earliest_reset { + Some(prev) => prev.min(reset), + None => reset, + }); + } + } + + // Prefer summed remaining when present; recompute used if inconsistent. + if remaining > 0.0 && total > 0.0 && used <= 0.0 { + used = (total - remaining).max(0.0); + } + if used < 0.0 { + used = 0.0; + } + if total < 0.0 { + total = 0.0; + } + if remaining < 0.0 { + remaining = (total - used).max(0.0); + } + + let used_percent = if total > 0.0 { + (used / total * 100.0).clamp(0.0, 100.0) + } else if used > 0.0 { + 100.0 + } else { + 0.0 + }; + + let description = Some(format!( + "{remaining:.2}/{total:.2} credits remaining ({used:.2} used, {} pkgs)", + accounts.len() + )); + + Ok(UsageSnapshot::new(RateWindow::with_details( + used_percent, + None, + earliest_reset, + description, + )) + .with_login_method("CodeBuddy CN")) +} + +fn snapshot_from_cache_payload(value: &Value) -> Result { + let total = number_field(value, &["total"]).ok_or_else(|| { + ProviderError::Parse("cb_credits.json missing total".into()) + })?; + let used = number_field(value, &["used"]).unwrap_or(0.0); + let remaining = number_field(value, &["remaining"]).unwrap_or_else(|| (total - used).max(0.0)); + let used_percent = if total > 0.0 { + (used / total * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let description = Some(format!( + "{remaining:.2}/{total:.2} credits remaining (local cache)" + )); + let reset = value + .get("updatedAt") + .and_then(|v| v.as_str()) + .and_then(parse_datetime); + Ok(UsageSnapshot::new(RateWindow::with_details( + used_percent, + None, + reset, + description, + )) + .with_login_method("CodeBuddy cache")) +} + +fn precise_or_field(account: &Value, precise_key: &str, fallback_key: &str) -> Option { + number_field(account, &[precise_key]).or_else(|| number_field(account, &[fallback_key])) +} + +fn expire_time(account: &Value) -> Option> { + // Common field names observed on Tencent package objects. + for key in [ + "ExpireTime", + "expireTime", + "ExpireTimeStamp", + "EndTime", + "endTime", + "ValidEndTime", + ] { + if let Some(v) = account.get(key) { + if let Some(s) = v.as_str() { + if let Some(dt) = parse_datetime(s) { + return Some(dt); + } + } + if let Some(n) = v.as_f64() { + let seconds = if n > 10_000_000_000.0 { n / 1000.0 } else { n }; + if let Some(dt) = DateTime::::from_timestamp(seconds as i64, 0) { + return Some(dt); + } + } + } + } + None +} + +fn number_field(value: &Value, keys: &[&str]) -> Option { + for key in keys { + match value.get(*key)? { + Value::Number(n) => return n.as_f64(), + Value::String(s) => { + if let Ok(n) = s.trim().parse::() { + return Some(n); + } + } + _ => {} + } + } + None +} + +fn parse_datetime(raw: &str) -> Option> { + if let Ok(number) = raw.parse::() { + let seconds = if number > 10_000_000_000.0 { + number / 1000.0 + } else { + number + }; + return DateTime::::from_timestamp(seconds as i64, 0); + } + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|| { + // "2026-08-07 12:00:00" style + chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|ndt| ndt.and_utc()) + }) +} + +#[async_trait] +impl Provider for CodeBuddyProvider { + fn id(&self) -> ProviderId { + ProviderId::CodeBuddy + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => match self.resolve_cookie(ctx) { + Ok(cookie) => match self.fetch_web(&cookie).await { + Ok(result) => Ok(result), + Err(err) if matches!(ctx.source_mode, SourceMode::Auto) => { + // Fall back to local normalized cache from statusline watch. + self.fetch_local_cache().or(Err(err)) + } + Err(err) => Err(err), + }, + Err(err) if matches!(ctx.source_mode, SourceMode::Auto) => { + self.fetch_local_cache().or(Err(err)) + } + Err(err) => Err(err), + }, + SourceMode::Cli => self.fetch_local_cache(), + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(ctx.source_mode)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] + } + + fn supports_web(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_get_user_resource_payload() { + let payload = serde_json::json!({ + "code": 0, + "msg": "ok", + "data": { + "Response": { + "Data": { + "Accounts": [ + { + "CapacitySizePrecise": "2000", + "CapacityUsedPrecise": "100", + "CapacityRemainPrecise": "1900", + "ExpireTime": "2026-09-01T00:00:00Z" + }, + { + "CapacitySize": 1100, + "CapacityUsed": 11, + "CapacityRemain": 1089 + } + ] + } + } + } + }); + let snapshot = snapshot_from_api_payload(&payload).unwrap(); + // used 111 / total 3100 ≈ 3.58% + assert!((snapshot.primary.used_percent - (111.0 / 3100.0 * 100.0)).abs() < 0.01); + assert!(snapshot + .primary + .reset_description + .as_deref() + .unwrap() + .contains("2989.00/3100.00")); + assert!(snapshot.primary.resets_at.is_some()); + } + + #[test] + fn empty_accounts_errors_with_hint() { + let payload = serde_json::json!({ + "code": 0, + "data": { "Response": { "Data": { "Accounts": [] } } } + }); + let err = snapshot_from_api_payload(&payload).unwrap_err(); + assert!(format!("{err}").contains("PackageCodes") || format!("{err}").contains("package")); + } + + #[test] + fn non_zero_code_errors() { + let payload = serde_json::json!({ "code": 14001, "msg": "quote exceeded" }); + assert!(snapshot_from_api_payload(&payload).is_err()); + } + + #[test] + fn parses_local_cache() { + let payload = serde_json::json!({ + "total": 3100, + "used": 111, + "remaining": 2989, + "source": "api", + "updatedAt": "2026-08-07T00:00:00Z" + }); + let snapshot = snapshot_from_cache_payload(&payload).unwrap(); + assert!((snapshot.primary.used_percent - (111.0 / 3100.0 * 100.0)).abs() < 0.01); + } + + #[test] + fn normalizes_cookie_with_caret_escapes() { + assert_eq!( + normalize_cookie_header("Cookie: a=1^|2; b=3").as_deref(), + Some("a=1|2; b=3") + ); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 5a1b8187af..288b2ee7c7 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -14,6 +14,7 @@ pub mod bedrock; pub mod chutes; pub mod claude; pub mod clinepass; +pub mod codebuddy; pub mod codebuff; pub mod codex; pub mod commandcode; @@ -85,6 +86,7 @@ pub use bedrock::BedrockProvider; pub use chutes::ChutesProvider; pub use claude::ClaudeProvider; pub use clinepass::ClinePassProvider; +pub use codebuddy::CodeBuddyProvider; pub use codebuff::CodebuffProvider; pub use codex::CodexProvider; pub use commandcode::CommandCodeProvider; From 9fa487de8df283685ebc1704d4649b612249e92d Mon Sep 17 00:00:00 2001 From: dongjimin Date: Fri, 7 Aug 2026 13:42:14 +0800 Subject: [PATCH 2/2] fix(codebuddy): compact credit label and harden flaky fetches Shorten tray metric text to 'N / total left', ellipsis long reset lines in the card row, retry transient WAF/proxy errors, and persist a local credits cache after successful web fetches for Auto fallback. --- apps/desktop-tauri/src/styles.css | 9 ++ rust/src/providers/codebuddy/mod.rs | 212 +++++++++++++++++++++++++--- 2 files changed, 201 insertions(+), 20 deletions(-) diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index e70ea32721..2e802f5498 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4228,6 +4228,7 @@ html:has(.menu-surface--tray) { align-items: baseline; justify-content: space-between; gap: 8px; + min-width: 0; /* allow children to shrink/ellipsis inside flex */ } .menu-metric__pct { @@ -4235,12 +4236,20 @@ html:has(.menu-surface--tray) { font-size: 11px; color: var(--text-primary); font-variant-numeric: tabular-nums; + flex: 0 0 auto; } .menu-metric__reset { font-size: 11px; color: var(--text-secondary); white-space: nowrap; + /* Long provider descriptions (credits remaining, etc.) used to overflow + the tray card because nowrap had no max-width/ellipsis. */ + min-width: 0; + max-width: 62%; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; } .menu-metric__exhausted { diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs index 9ce7746678..bb781b5230 100644 --- a/rust/src/providers/codebuddy/mod.rs +++ b/rust/src/providers/codebuddy/mod.rs @@ -73,11 +73,36 @@ impl CodeBuddyProvider { async fn fetch_web(&self, cookie_header: &str) -> Result { let cookie = normalize_cookie_header(cookie_header).ok_or(ProviderError::NoCookies)?; + // One retry for transient proxy/WAF/network flakes (common on EdgeOne). + let mut last_err = None; + for attempt in 0..2u8 { + match self.fetch_web_once(&cookie).await { + Ok(result) => { + // Persist a short cache so Auto can soft-fail on the next flake. + if let Err(err) = write_credits_cache_from_snapshot(&result.usage) { + tracing::debug!(?err, "CodeBuddy: failed to write local credits cache"); + } + return Ok(result); + } + Err(err) => { + let retryable = is_retryable_error(&err); + last_err = Some(err); + if !retryable || attempt == 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + } + } + } + Err(last_err.unwrap_or(ProviderError::Other("CodeBuddy fetch failed".into()))) + } + + async fn fetch_web_once(&self, cookie: &str) -> Result { let body = request_body(); let response = self .client .post(CN_API) - .header("Cookie", &cookie) + .header("Cookie", cookie) .header("Accept", "application/json, text/plain, */*") .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7") .header("Content-Type", "application/json") @@ -90,21 +115,40 @@ impl CodeBuddyProvider { .header("sec-fetch-site", "same-origin") .json(&body) .send() - .await?; + .await + .map_err(ProviderError::from)?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return Err(ProviderError::AuthRequired); } + if status.as_u16() == 429 + || status.is_server_error() + || status == reqwest::StatusCode::BAD_GATEWAY + { + return Err(ProviderError::Other(format!( + "CodeBuddy temporary HTTP {status}" + ))); + } if !status.is_success() { return Err(ProviderError::Other(format!( "CodeBuddy get-user-resource returned HTTP {status}" ))); } - let value = response - .json::() - .await + let bytes = response.bytes().await.map_err(ProviderError::from)?; + // EdgeOne sometimes returns HTML 200/401 pages instead of JSON. + if bytes + .iter() + .find(|b| !b.is_ascii_whitespace()) + .is_some_and(|b| *b == b'<') + { + return Err(ProviderError::Other( + "CodeBuddy WAF/HTML response (retry later)".into(), + )); + } + + let value: Value = serde_json::from_slice(&bytes) .map_err(|e| ProviderError::Parse(format!("Failed to parse CodeBuddy response: {e}")))?; let snapshot = snapshot_from_api_payload(&value)?; @@ -311,10 +355,9 @@ fn snapshot_from_api_payload(value: &Value) -> Result Result Result String { + format!( + "{} / {} left", + format_credit_number(remaining), + format_credit_number(total) + ) +} + +fn format_credit_number(value: f64) -> String { + if !value.is_finite() { + return "0".into(); + } + let rounded = (value * 100.0).round() / 100.0; + if (rounded - rounded.round()).abs() < 0.001 { + format_int_with_commas(rounded.round() as i64) + } else { + let s = format!("{rounded:.2}"); + let trimmed = s.trim_end_matches('0').trim_end_matches('.'); + // Insert commas into the integer part only. + if let Some((whole, frac)) = trimmed.split_once('.') { + format!( + "{}.{}", + format_int_with_commas(whole.parse().unwrap_or(0)), + frac + ) + } else { + format_int_with_commas(trimmed.parse().unwrap_or(0)) + } + } +} + +fn format_int_with_commas(value: i64) -> String { + let negative = value < 0; + let digits = value.unsigned_abs().to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + let mut s: String = out.chars().rev().collect(); + if negative { + s.insert(0, '-'); + } + s +} + +fn write_credits_cache_from_snapshot(usage: &UsageSnapshot) -> Result<(), String> { + let path = credits_cache_path().ok_or_else(|| "no cache path".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + // Recover remaining/total from description when possible is fragile; store + // used_percent + free-form fields derived from description text is worse. + // Instead re-parse from the short description format "X / Y left". + let (remaining, total) = parse_short_credits_desc( + usage + .primary + .reset_description + .as_deref() + .unwrap_or_default(), + ) + .unwrap_or((0.0, 0.0)); + let used = if total > 0.0 { + (total - remaining).max(0.0) + } else { + 0.0 + }; + let mut obj = serde_json::Map::new(); + obj.insert("total".into(), json!(round2(total))); + obj.insert("used".into(), json!(round2(used))); + obj.insert("remaining".into(), json!(round2(remaining))); + obj.insert("source".into(), json!("api")); + obj.insert("updatedAt".into(), json!(Utc::now().to_rfc3339())); + if let Some(resets) = usage.primary.resets_at { + obj.insert("resetsAt".into(), json!(resets.to_rfc3339())); + } + std::fs::write( + path, + serde_json::to_string_pretty(&Value::Object(obj)).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) +} + +fn parse_short_credits_desc(desc: &str) -> Option<(f64, f64)> { + // "1,234.5 / 5,678 left" + let left = desc.split(" left").next()?.trim(); + let (rem_s, tot_s) = left.split_once('/')?; + let remaining = rem_s.trim().replace(',', "").parse::().ok()?; + let total = tot_s.trim().replace(',', "").parse::().ok()?; + Some((remaining, total)) +} + +fn round2(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +fn is_retryable_error(err: &ProviderError) -> bool { + match err { + ProviderError::Network(_) => true, + ProviderError::Other(msg) => { + let lower = msg.to_ascii_lowercase(); + lower.contains("temporary") + || lower.contains("waf") + || lower.contains("timeout") + || lower.contains("timed out") + || lower.contains("connection") + || lower.contains("http 429") + || lower.contains("http 5") + || lower.contains("502") + || lower.contains("503") + || lower.contains("504") + } + ProviderError::Parse(msg) => { + // Transient HTML/WAF sometimes fails JSON parse mid-body. + let lower = msg.to_ascii_lowercase(); + lower.contains("html") || lower.contains("eof") || lower.contains("expected") + } + _ => false, + } } fn precise_or_field(account: &Value, precise_key: &str, fallback_key: &str) -> Option { @@ -490,15 +658,19 @@ mod tests { let snapshot = snapshot_from_api_payload(&payload).unwrap(); // used 111 / total 3100 ≈ 3.58% assert!((snapshot.primary.used_percent - (111.0 / 3100.0 * 100.0)).abs() < 0.01); - assert!(snapshot - .primary - .reset_description - .as_deref() - .unwrap() - .contains("2989.00/3100.00")); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("2,989 / 3,100 left") + ); assert!(snapshot.primary.resets_at.is_some()); } + #[test] + fn formats_compact_credit_labels() { + assert_eq!(format_credits_short(1989.0, 3100.0), "1,989 / 3,100 left"); + assert_eq!(format_credits_short(12.5, 100.0), "12.5 / 100 left"); + } + #[test] fn empty_accounts_errors_with_hint() { let payload = serde_json::json!({