Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,26 @@ impl ApiClient {
e, response_text
)
})?;
UsageData::Code88(resp.data)
let data = resp.data;

// 检查 usage API 数据是否有效
// 如果无效(creditLimit=null, subscriptionEntityList=null),fallback 到 subscription API
if !data.is_valid() {
// Fallback: 从 subscription API 获取数据
match self.get_subscriptions(model) {
Ok(subscriptions) => {
let fallback_data =
super::Code88UsageData::from_subscriptions(&subscriptions);
UsageData::Code88(fallback_data)
}
Err(_) => {
// subscription API 也失败了,返回原始数据(可能显示异常)
UsageData::Code88(data)
}
}
} else {
UsageData::Code88(data)
}
} else {
// Packy 及其他中转站:使用 Packy 格式解析
let resp: super::PackyUsageResponse =
Expand Down
157 changes: 150 additions & 7 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
pub mod cache;
pub mod client;

use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use std::path::PathBuf;

/// 自定义反序列化:将 null 转换为默认值 0.0
fn deserialize_null_as_zero<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<f64>::deserialize(deserializer)?;
Ok(opt.unwrap_or(0.0))
}

/// 自定义反序列化:将 null 转换为空 Vec
fn deserialize_null_as_empty_vec<'de, D>(
deserializer: D,
) -> Result<Vec<SubscriptionEntity>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<Vec<SubscriptionEntity>>::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiConfig {
pub enabled: bool,
Expand Down Expand Up @@ -67,15 +87,27 @@ pub enum UsageData {

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Code88UsageData {
#[serde(rename = "totalTokens")]
#[serde(rename = "totalTokens", default)]
pub total_tokens: u64,
#[serde(rename = "creditLimit")]
#[serde(
rename = "creditLimit",
default,
deserialize_with = "deserialize_null_as_zero"
)]
pub credit_limit: f64,
#[serde(rename = "currentCredits")]
#[serde(
rename = "currentCredits",
default,
deserialize_with = "deserialize_null_as_zero"
)]
pub current_credits: f64,

/// 订阅实体列表,包含所有套餐的详细信息
#[serde(rename = "subscriptionEntityList", default)]
#[serde(
rename = "subscriptionEntityList",
default,
deserialize_with = "deserialize_null_as_empty_vec"
)]
pub subscription_entity_list: Vec<SubscriptionEntity>,

#[serde(default)]
Expand Down Expand Up @@ -171,9 +203,25 @@ impl UsageData {
UsageData::Packy(_) => false, // Packy 不支持
}
}

/// 判断 usage 数据是否有效(用于检测 API 是否返回了有效数据)
/// 如果无效,需要 fallback 到 subscription API
pub fn is_valid(&self) -> bool {
match self {
UsageData::Code88(data) => data.is_valid(),
UsageData::Packy(_) => true, // Packy 格式不受影响
}
}
}

impl Code88UsageData {
/// 判断 usage API 返回的数据是否有效
/// 当 API 返回 creditLimit=null, subscriptionEntityList=null 时为无效
pub fn is_valid(&self) -> bool {
// 有效条件:creditLimit > 0 或 subscriptionEntityList 非空
self.credit_limit > 0.0 || !self.subscription_entity_list.is_empty()
}

pub fn calculate(&mut self) {
// 从 subscriptionEntityList 中找到正在扣费的套餐
// Claude Code 环境下跳过 FREE 套餐(FREE 不支持 CC)
Expand Down Expand Up @@ -269,6 +317,15 @@ impl PackyUsageData {
}
}

/// 套餐计划详情(嵌套在 SubscriptionData 中)
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct SubscriptionPlan {
#[serde(rename = "creditLimit", default)]
pub credit_limit: f64,
#[serde(rename = "subscriptionName", default)]
pub subscription_name: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubscriptionData {
#[serde(rename = "subscriptionPlanName")]
Expand All @@ -289,19 +346,105 @@ pub struct SubscriptionData {
/// 当前剩余额度(美元)- 用于 PAYGO 等套餐显示
#[serde(rename = "currentCredits", default)]
pub current_credits: f64,
/// 套餐总额度(美元)- 用于计算进度条
/// 套餐总额度(美元)- 顶层可能没有,需要从 subscription_plan 获取
#[serde(rename = "creditLimit", default)]
pub credit_limit: f64,
/// 套餐计划详情 - 包含 creditLimit 等信息
#[serde(rename = "subscriptionPlan", default)]
pub subscription_plan: SubscriptionPlan,
/// 订阅 ID - 用于排序(越小越早购买)
#[serde(default)]
pub id: i64,

// 计算字段
#[serde(skip)]
pub plan_price: String,
}

impl SubscriptionData {
/// 格式化显示数据
/// 格式化显示数据,并从 subscription_plan 补充 credit_limit
pub fn format(&mut self) {
self.plan_price = format!("¥{}/{}", self.cost, self.billing_cycle_desc);
// 如果顶层 credit_limit 为 0,从 subscription_plan 获取
if self.credit_limit == 0.0 && self.subscription_plan.credit_limit > 0.0 {
self.credit_limit = self.subscription_plan.credit_limit;
}
}

/// 获取扣费优先级(用于排序)
/// PLUS/PRO/MAX = 1, PAYGO = 2, FREE = 3
fn billing_priority(&self) -> u8 {
match self.plan_name.to_uppercase().as_str() {
"FREE" => 3,
"PAYGO" => 2,
_ => 1, // PLUS, PRO, MAX 等
}
}
}

impl Code88UsageData {
/// 从 subscription 数据构造 UsageData(fallback 方案)
/// 当 /api/usage 返回无效数据时使用
pub fn from_subscriptions(subscriptions: &[SubscriptionData]) -> Self {
// 筛选活跃套餐:is_active && status == "活跃中"
let mut active_subs: Vec<&SubscriptionData> = subscriptions
.iter()
.filter(|s| s.is_active)
.filter(|s| s.status == "活跃中")
.collect();

// 按扣费优先级排序:PLUS/PRO/MAX > PAYGO > FREE
// 同优先级按 id 排序(越小越早购买)
active_subs.sort_by(|a, b| {
a.billing_priority()
.cmp(&b.billing_priority())
.then(a.id.cmp(&b.id))
});

// 跳过 FREE,找第一个有消费的(current_credits < credit_limit)
let current_sub = active_subs
.iter()
.filter(|s| s.plan_name.to_uppercase() != "FREE")
.find(|s| s.current_credits < s.credit_limit);

// 如果没找到有消费的,取第一个非 FREE 有余额的(fallback)
let current_sub = current_sub.or_else(|| {
active_subs
.iter()
.filter(|s| s.plan_name.to_uppercase() != "FREE")
.find(|s| s.current_credits > 0.0)
});

// 构造 subscription_entity_list
let subscription_entity_list: Vec<SubscriptionEntity> = active_subs
.iter()
.map(|s| SubscriptionEntity {
subscription_name: s.plan_name.clone(),
credit_limit: s.credit_limit,
current_credits: s.current_credits,
is_active: s.is_active,
})
.collect();

// 获取当前套餐的数据
let (credit_limit, current_credits) = match current_sub {
Some(sub) => (sub.credit_limit, sub.current_credits),
None => (0.0, 0.0),
};

let mut data = Code88UsageData {
total_tokens: 0,
credit_limit,
current_credits,
subscription_entity_list,
used_tokens: 0,
remaining_tokens: 0,
percentage_used: 0.0,
};

// 计算 used_tokens, remaining_tokens, percentage_used
data.calculate();
data
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/core/segments/byebyecode_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ pub fn collect(config: &Config, input: &InputData) -> Option<SegmentData> {
let model_id = &input.model.id;

// 优先使用缓存,API 失败时降级
let usage = fetch_usage_with_cache(&api_key, &usage_url, Some(model_id), service_name);
let usage = fetch_usage_with_cache(
&api_key,
&usage_url,
&subscription_url,
Some(model_id),
service_name,
);

let usage = match usage {
Some(u) => u,
Expand All @@ -116,14 +122,15 @@ pub fn collect(config: &Config, input: &InputData) -> Option<SegmentData> {
fn fetch_usage_with_cache(
api_key: &str,
usage_url: &str,
subscription_url: &str,
model: Option<&str>,
_service_name: &str,
) -> Option<crate::api::UsageData> {
let api_config = ApiConfig {
enabled: true,
api_key: api_key.to_string(),
usage_url: usage_url.to_string(),
subscription_url: String::new(),
subscription_url: subscription_url.to_string(),
};

// 尝试从 API 获取
Expand Down