F3: Kimi inference pipeline + full grok cloud-surface excision
Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
This commit is contained in:
1 parent
fe1f885bb3
commit
ea0ce9d15f
231 files changed
+4730
-26358
No files matched your search
@@ -43,9 +43,6 @@ pub(super) const SESSION_SEARCH_DEBOUNCE_MS: u64 = 250;
|
||||
/// All other errors are sanitized to remove internal service names and jargon.
|
||||
pub(super) fn format_acp_error(err: &acp::Error, is_api_key_auth: bool) -> String {
|
||||
if i32::from(err.code) == RATE_LIMITED_ERROR_CODE {
|
||||
if super::dispatch::acp_error_is_free_usage_exhausted(err) {
|
||||
return super::dispatch::FREE_USAGE_USER_MESSAGE.into();
|
||||
}
|
||||
return rate_limited_user_message(is_api_key_auth).into();
|
||||
}
|
||||
if err.code == acp::ErrorCode::InvalidParams && let Some(data) = &err.data
|
||||
@@ -132,8 +129,6 @@ pub(crate) fn sanitize_user_error(raw: &str) -> String {
|
||||
return "Out of disk space.".to_string();
|
||||
}
|
||||
static REPLACEMENTS: &[(&str, &str)] = &[
|
||||
("cli-chat-proxy", "server"),
|
||||
("cli_chat_proxy", "server"),
|
||||
("inference-api", "server"),
|
||||
("inference_api", "server"),
|
||||
("research-api", "server"),
|
||||
@@ -365,41 +360,6 @@ pub(super) fn count_chat_history_stats(history_path: &Path) -> (usize, usize) {
|
||||
}
|
||||
(turn_count, tool_call_count)
|
||||
}
|
||||
/// Degraded conversations lane on `x.ai/session/list`, parsed from the
|
||||
/// response's `_meta["x.ai/partial"]` envelope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConversationsPartial {
|
||||
NoOauth,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
impl ConversationsPartial {
|
||||
/// Actionable picker notice for a degraded conversations lane.
|
||||
pub(crate) fn picker_notice(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoOauth => "Couldn't load your chats \u{2014} log in with /login",
|
||||
Self::Timeout | Self::Error => "Couldn't load conversations \u{2014} retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Read `_meta["x.ai/partial"]` from a session-list payload. `None` when the
|
||||
/// conversations lane completed (or was skipped); unknown reasons degrade to
|
||||
/// [`ConversationsPartial::Error`].
|
||||
pub(super) fn parse_session_list_partial(
|
||||
payload: &serde_json::Value,
|
||||
) -> Option<ConversationsPartial> {
|
||||
let partial = payload.get("_meta")?.get("x.ai/partial")?;
|
||||
if partial.get("conversations").and_then(|v| v.as_bool()) != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
match partial.get("reason").and_then(|v| v.as_str()) {
|
||||
Some("no_oauth") => ConversationsPartial::NoOauth,
|
||||
Some("timeout") => ConversationsPartial::Timeout,
|
||||
_ => ConversationsPartial::Error,
|
||||
},
|
||||
)
|
||||
}
|
||||
/// Parse the `x.ai/session/list` response payload (the unwrapped
|
||||
/// `{ "sessions": [...] }` object) into [`SessionPickerEntry`] rows.
|
||||
///
|
||||
@@ -597,73 +557,6 @@ pub(super) async fn send_logout(tx: &AcpAgentTx) {
|
||||
tracing::warn!(error = % e, "logout failed");
|
||||
}
|
||||
}
|
||||
pub(super) async fn send_check_subscription(
|
||||
tx: &AcpAgentTx,
|
||||
verify: Option<u64>,
|
||||
) -> TaskResult {
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auth/check_subscription",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize check_subscription params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, tx).await {
|
||||
Ok(resp) => {
|
||||
let meta = serde_json::from_str::<serde_json::Value>(resp.0.get())
|
||||
.ok()
|
||||
.and_then(|v| v.get("meta").cloned());
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "check_subscription failed");
|
||||
crate::unified_log::warn(
|
||||
"subscription.check.rpc_failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "verify" : verify, "error" : e.to_string(), })),
|
||||
);
|
||||
TaskResult::CheckSubscriptionComplete {
|
||||
verify,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One-shot subscription re-check for the credit-limit retry flow.
|
||||
/// Same ACP call as `send_check_subscription` but returns a
|
||||
/// `CreditLimitRecheckComplete` so the dispatch layer can decide
|
||||
/// whether to retry the stashed prompt or show the upsell.
|
||||
pub(super) async fn send_credit_limit_recheck(
|
||||
tx: &AcpAgentTx,
|
||||
agent_id: AgentId,
|
||||
) -> TaskResult {
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auth/check_subscription",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize check_subscription params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, tx).await {
|
||||
Ok(resp) => {
|
||||
let meta = serde_json::from_str::<serde_json::Value>(resp.0.get())
|
||||
.ok()
|
||||
.and_then(|v| v.get("meta").cloned());
|
||||
TaskResult::CreditLimitRecheckComplete {
|
||||
agent_id,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "credit_limit_recheck failed");
|
||||
TaskResult::CreditLimitRecheckComplete {
|
||||
agent_id,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) async fn send_authenticate(
|
||||
tx: &AcpAgentTx,
|
||||
request_seq: u64,
|
||||
@@ -1199,125 +1092,16 @@ pub(super) fn persist_hint(
|
||||
TaskResult::CancelComplete
|
||||
});
|
||||
}
|
||||
/// Map a billing config into a [`CreditBalance`].
|
||||
///
|
||||
/// Prefers the newer credits-config fields (`credit_usage_percent`,
|
||||
/// `current_period`) and falls back to the deprecated
|
||||
/// `monthly_limit`/`used`/`billing_period_end`. Shared by `Effect::FetchBilling`
|
||||
/// and `Effect::FetchAppBilling` so every pager UI path derives identical usage
|
||||
/// values from the same config.
|
||||
pub(super) fn credit_balance_from_config(
|
||||
c: kigi_shell::extensions::billing::BillingConfig,
|
||||
) -> crate::views::credit_bar::CreditBalance {
|
||||
let limit = c.monthly_limit.map(|v| v.val).unwrap_or(0);
|
||||
let used = c.used.map(|v| v.val).unwrap_or(0);
|
||||
let has_credit_pct = c.credit_usage_percent.is_some();
|
||||
let usage_pct = match c.credit_usage_percent {
|
||||
Some(pct) => pct.clamp(0.0, 100.0),
|
||||
None if limit > 0 => (used as f64 / limit as f64 * 100.0).min(100.0),
|
||||
None => 0.0,
|
||||
};
|
||||
let period_end_display = c
|
||||
.current_period
|
||||
.as_ref()
|
||||
.and_then(|p| p.end.clone())
|
||||
.or(c.billing_period_end)
|
||||
.and_then(|s| {
|
||||
chrono::DateTime::parse_from_rfc3339(&s)
|
||||
.ok()
|
||||
.map(|dt| {
|
||||
dt.with_timezone(&chrono::Local).format("%B %-d, %H:%M").to_string()
|
||||
})
|
||||
});
|
||||
let on_demand_val = c.on_demand_cap.map(|v| v.val).unwrap_or(0);
|
||||
let pay_as_you_go = on_demand_val > 0;
|
||||
let on_demand_cap_cents = if on_demand_val > 0 { Some(on_demand_val) } else { None };
|
||||
let on_demand_used_cents = c
|
||||
.on_demand_used
|
||||
.map(|v| v.val)
|
||||
.unwrap_or_else(|| (used - limit).max(0));
|
||||
let effective_usage_pct = if on_demand_val > 0 {
|
||||
if usage_pct >= 100.0 {
|
||||
(on_demand_used_cents as f64 / on_demand_val as f64 * 100.0).min(100.0)
|
||||
} else if has_credit_pct {
|
||||
usage_pct
|
||||
} else {
|
||||
let total_budget = limit + on_demand_val;
|
||||
if total_budget > 0 {
|
||||
(used as f64 / total_budget as f64 * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage_pct
|
||||
};
|
||||
let period_type = c.current_period.as_ref().and_then(|p| p.period_type.clone());
|
||||
crate::views::credit_bar::CreditBalance {
|
||||
usage_pct,
|
||||
effective_usage_pct,
|
||||
period_end_display,
|
||||
pay_as_you_go,
|
||||
on_demand_cap_cents,
|
||||
on_demand_used_cents: Some(on_demand_used_cents),
|
||||
prepaid_balance_cents: c.prepaid_balance.map(|v| v.val),
|
||||
period_type,
|
||||
is_unified_billing_user: c.is_unified_billing_user,
|
||||
}
|
||||
}
|
||||
/// Whether the balance carries a non-zero prepaid credit balance (signed cents).
|
||||
pub(super) fn has_prepaid_credits(
|
||||
balance: Option<&crate::views::credit_bar::CreditBalance>,
|
||||
) -> bool {
|
||||
balance.and_then(|b| b.prepaid_balance_cents).map(i64::abs).is_some_and(|c| c > 0)
|
||||
}
|
||||
/// Fetch the user's auto top-up rule via the `x.ai/auto-topup-rule` extension.
|
||||
/// A transport failure yields [`AutoTopupFetch::Unchanged`] so the caller keeps
|
||||
/// any cached rule rather than treating the blip as "no auto top-up".
|
||||
pub(super) async fn fetch_auto_topup_info(
|
||||
tx: &kigi_acp_lib::AcpAgentTx,
|
||||
) -> crate::views::credit_bar::AutoTopupFetch {
|
||||
use crate::views::credit_bar::AutoTopupFetch;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/auto-topup-rule",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize auto-topup params")
|
||||
.into(),
|
||||
);
|
||||
let Ok(resp) = acp_send(req, tx).await else {
|
||||
return AutoTopupFetch::Unchanged;
|
||||
};
|
||||
let wrapper: serde_json::Value = serde_json::from_str(resp.0.get())
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
parse_auto_topup_response(result)
|
||||
}
|
||||
/// Map an `x.ai/auto-topup-rule` payload to an [`AutoTopupFetch`]. A body that
|
||||
/// fails to deserialize is a fetch error (→ `Unchanged`, keep the cached rule),
|
||||
/// not a definitive "no rule", so a malformed response can't silently flip the
|
||||
/// credits warning.
|
||||
pub(super) fn parse_auto_topup_response(
|
||||
/// Parse an `x.ai/billing` ext response body (the unwrapped `result`
|
||||
/// payload) into Kimi usage rows. A body that fails to deserialize is an
|
||||
/// error, not an empty quota list, so a malformed response can't render
|
||||
/// as "no usage data".
|
||||
pub(super) fn parse_usage_response(
|
||||
result: &serde_json::Value,
|
||||
) -> crate::views::credit_bar::AutoTopupFetch {
|
||||
use crate::views::credit_bar::{AutoTopupFetch, AutoTopupInfo};
|
||||
use kigi_shell::extensions::billing::GetAutoTopupRuleResponse;
|
||||
match serde_json::from_value::<GetAutoTopupRuleResponse>(result.clone()) {
|
||||
Ok(parsed) => {
|
||||
AutoTopupFetch::Resolved(
|
||||
parsed
|
||||
.rule
|
||||
.map_or_else(
|
||||
AutoTopupInfo::disabled,
|
||||
|rule| AutoTopupInfo {
|
||||
enabled: rule.enabled,
|
||||
topup_amount_cents: rule.topup_amount.map(|c| c.val),
|
||||
max_amount_cents: rule.max_amount_per_month.map(|c| c.val),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
Err(_) => AutoTopupFetch::Unchanged,
|
||||
}
|
||||
) -> Result<Vec<kigi_shell::extensions::billing::UsageRow>, String> {
|
||||
serde_json::from_value::<kigi_shell::extensions::billing::UsageResponse>(result.clone())
|
||||
.map(|usage| usage.rows)
|
||||
.map_err(|e| format!("Parse error: {e}"))
|
||||
}
|
||||
/// A blocking flock on the shared, possibly-network `~/.kigi` lock must never
|
||||
/// stall the event-loop thread (and would hang exit on `/quit`); the registry
|
||||
|
||||
@@ -9,7 +9,6 @@ mod helpers;
|
||||
use super::actions;
|
||||
#[allow(unused_imports)]
|
||||
use super::{agent, dispatch};
|
||||
pub use helpers::ConversationsPartial;
|
||||
pub(super) use helpers::parse_session_load_running_prompt_id;
|
||||
pub(crate) use helpers::{
|
||||
EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify,
|
||||
@@ -79,31 +78,6 @@ pub(crate) fn execute(
|
||||
TaskResult::LogoutComplete
|
||||
});
|
||||
}
|
||||
Effect::CheckSubscription { verify } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks.spawn(async move { send_check_subscription(&tx, verify).await });
|
||||
}
|
||||
Effect::CreditLimitRecheck { agent_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks.spawn(async move { send_credit_limit_recheck(&tx, agent_id).await });
|
||||
}
|
||||
Effect::SchedulePaywallCheck => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
TaskResult::PaywallCheckTick
|
||||
});
|
||||
}
|
||||
Effect::ScheduleGateVerifyTimeout { generation } => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
tokio::time::sleep(crate::app::subscription::GATE_VERIFY_TIMEOUT)
|
||||
.await;
|
||||
TaskResult::GateVerifyTimeout {
|
||||
generation,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SwitchAccount { request_seq, method_id, use_oauth } => {
|
||||
let tx = acp_tx.clone();
|
||||
let abort_handle = tasks
|
||||
@@ -717,10 +691,8 @@ pub(crate) fn execute(
|
||||
}
|
||||
let payload = wrapper.get("result").unwrap_or(&wrapper);
|
||||
let sessions = parse_session_picker_entries(payload);
|
||||
let partial = parse_session_list_partial(payload);
|
||||
TaskResult::SessionListLoaded {
|
||||
sessions,
|
||||
partial,
|
||||
seq,
|
||||
query,
|
||||
}
|
||||
@@ -2414,66 +2386,6 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ShareSession { agent_id, session_id } => {
|
||||
use kigi_shell::session::{ShareSessionRequest, ShareSessionResponse};
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let request = acp::ExtRequest::new(
|
||||
"x.ai/share_session",
|
||||
serde_json::value::to_raw_value(
|
||||
&ShareSessionRequest {
|
||||
session_id: session_id.0.to_string(),
|
||||
},
|
||||
)
|
||||
.expect("serialize share session params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(request, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
if let Some(err) = wrapper.get("error") {
|
||||
let msg = err
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "unknown error".to_string());
|
||||
return TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: msg,
|
||||
};
|
||||
}
|
||||
let inner = wrapper.get("result").unwrap_or(&wrapper);
|
||||
match serde_json::from_value::<
|
||||
ShareSessionResponse,
|
||||
>(inner.clone()) {
|
||||
Ok(share_resp) => {
|
||||
TaskResult::ShareSessionComplete {
|
||||
agent_id,
|
||||
share_url: share_resp.share_url,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: "couldn't share session".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
TaskResult::ShareSessionFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!("couldn't share session: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchSessionAgentName { agent_id, session_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
@@ -2638,68 +2550,6 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SetCodingDataSharing { agent_id, opted_in, rollback_to_opted_in } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let request = acp::ExtRequest::new(
|
||||
"x.ai/privacy/setCodingDataRetention",
|
||||
serde_json::value::to_raw_value(
|
||||
&serde_json::json!(
|
||||
{ "codingDataRetentionOptOut" : ! opted_in }
|
||||
),
|
||||
)
|
||||
.expect("serialize params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(request, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = match serde_json::from_str(
|
||||
resp.0.get(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: format!("malformed response: {e}"),
|
||||
rollback_to_opted_in,
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(err) = wrapper
|
||||
.get("error")
|
||||
.filter(|v| !v.is_null())
|
||||
{
|
||||
let msg = err
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| err.to_string());
|
||||
return TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: msg,
|
||||
rollback_to_opted_in,
|
||||
};
|
||||
}
|
||||
let confirmed_opted_in = wrapper
|
||||
.get("codingDataRetentionOptOut")
|
||||
.and_then(|v| v.as_bool())
|
||||
.map(|opt_out| !opt_out)
|
||||
.unwrap_or(opted_in);
|
||||
TaskResult::CodingDataSharingUpdated {
|
||||
agent_id,
|
||||
opted_in: confirmed_opted_in,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
TaskResult::CodingDataSharingFailed {
|
||||
agent_id,
|
||||
error: format!("{e}"),
|
||||
rollback_to_opted_in,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ShowContextInfo { agent_id, session_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
@@ -3453,150 +3303,30 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchBilling { agent_id, silent } => {
|
||||
Effect::FetchUsage { agent_id } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
use kigi_shell::extensions::billing::BillingConfigResponse;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/billing",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize billing params")
|
||||
.expect("serialize usage params")
|
||||
.into(),
|
||||
);
|
||||
let parsed = match acp_send(req, &tx).await {
|
||||
let result = match acp_send(req, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
serde_json::from_value::<
|
||||
BillingConfigResponse,
|
||||
>(result.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
return TaskResult::BillingError {
|
||||
agent_id,
|
||||
error: sanitize_user_error(&format!("{e}")),
|
||||
silent,
|
||||
};
|
||||
let payload = wrapper.get("result").unwrap_or(&wrapper);
|
||||
parse_usage_response(payload)
|
||||
}
|
||||
Err(e) => Err(sanitize_user_error(&format!("{e}"))),
|
||||
};
|
||||
let billing = match parsed {
|
||||
Ok(billing) => billing,
|
||||
Err(e) => {
|
||||
return TaskResult::BillingError {
|
||||
agent_id,
|
||||
error: format!("Parse error: {e}"),
|
||||
silent,
|
||||
};
|
||||
}
|
||||
};
|
||||
let subscription_tier = billing.subscription_tier;
|
||||
let balance = billing.config.map(credit_balance_from_config);
|
||||
let autotopup = if has_prepaid_credits(balance.as_ref()) {
|
||||
fetch_auto_topup_info(&tx).await
|
||||
} else {
|
||||
crate::views::credit_bar::AutoTopupFetch::Cleared
|
||||
};
|
||||
TaskResult::BillingFetched {
|
||||
TaskResult::UsageFetched {
|
||||
agent_id,
|
||||
balance,
|
||||
silent,
|
||||
subscription_tier,
|
||||
autotopup,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::RefreshGate => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let settings = tokio::task::spawn_blocking(|| {
|
||||
if !kigi_shell::util::config::resolve_remote_fetch_enabled() {
|
||||
return None;
|
||||
}
|
||||
let kigi_home = kigi_shell::util::kigi_home::kigi_home();
|
||||
let store = kigi_shell::auth::read_auth_json(
|
||||
&kigi_home.join("auth.json"),
|
||||
)
|
||||
.ok()?;
|
||||
let scope = kigi_shell::auth::KimiCodeConfig::default()
|
||||
.auth_scope();
|
||||
let auth = kigi_shell::auth::lookup_auth(
|
||||
&store,
|
||||
&scope,
|
||||
)?;
|
||||
let proxy_base = std::env::var(
|
||||
"KIGI_CLI_CHAT_PROXY_BASE_URL",
|
||||
)
|
||||
.unwrap_or_else(|_| kigi_env::coding_api_base_url());
|
||||
kigi_shell::remote::fetch_settings_blocking(
|
||||
&proxy_base,
|
||||
&auth,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
TaskResult::GateRefreshed {
|
||||
settings,
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::FetchAppBilling => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
use kigi_shell::extensions::billing::BillingConfigResponse;
|
||||
let req = acp::ExtRequest::new(
|
||||
"x.ai/billing",
|
||||
serde_json::value::to_raw_value(&serde_json::json!({}))
|
||||
.expect("serialize billing params")
|
||||
.into(),
|
||||
);
|
||||
match acp_send(req, &tx).await {
|
||||
Ok(resp) => {
|
||||
let wrapper: serde_json::Value = serde_json::from_str(
|
||||
resp.0.get(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let result = wrapper.get("result").unwrap_or(&wrapper);
|
||||
match serde_json::from_value::<
|
||||
BillingConfigResponse,
|
||||
>(result.clone()) {
|
||||
Ok(billing) => {
|
||||
let balance = billing
|
||||
.config
|
||||
.map(|c| crate::views::credit_bar::CreditBalance {
|
||||
period_end_display: None,
|
||||
..credit_balance_from_config(c)
|
||||
});
|
||||
let autotopup = if has_prepaid_credits(balance.as_ref()) {
|
||||
fetch_auto_topup_info(&tx).await
|
||||
} else {
|
||||
crate::views::credit_bar::AutoTopupFetch::Cleared
|
||||
};
|
||||
TaskResult::AppBillingFetched {
|
||||
balance,
|
||||
autotopup,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::AppBillingFetched {
|
||||
balance: None,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
TaskResult::AppBillingFetched {
|
||||
balance: None,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged,
|
||||
}
|
||||
}
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
use super::*;
|
||||
use kigi_shell::extensions::billing::{BillingConfig, Cent, UsagePeriod};
|
||||
/// The invalid-params server detail survives `attach_prompt_usage`
|
||||
/// wrapping `error.data` as `{message, promptUsage}`.
|
||||
#[test]
|
||||
@@ -23,7 +22,7 @@ fn format_acp_error_rate_limit_is_auth_aware() {
|
||||
RATE_LIMITED_USER_MESSAGE_OAUTH,
|
||||
};
|
||||
let err = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited").data("slow down");
|
||||
assert_eq!(format_acp_error(& err, false), RATE_LIMITED_USER_MESSAGE_OAUTH);
|
||||
assert_eq!(format_acp_error(&err, false), RATE_LIMITED_USER_MESSAGE_OAUTH.as_str());
|
||||
assert_eq!(format_acp_error(& err, true), RATE_LIMITED_USER_MESSAGE_API_KEY);
|
||||
}
|
||||
/// Non-empty token ranges ride the wire block meta as `skillTokenRanges`
|
||||
@@ -138,43 +137,6 @@ fn picker_still_drops_build_row_with_empty_summary() {
|
||||
assert!(entries.is_empty(), "empty-summary Build rows stay dropped");
|
||||
}
|
||||
#[test]
|
||||
fn session_list_partial_parses_reasons() {
|
||||
let payload = |reason: &str| {
|
||||
serde_json::json!(
|
||||
{ "sessions" : [], "_meta" : { "x.ai/partial" : { "conversations" : true,
|
||||
"reason" : reason } } }
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("no_oauth")),
|
||||
Some(ConversationsPartial::NoOauth)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("timeout")),
|
||||
Some(ConversationsPartial::Timeout)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("error")), Some(ConversationsPartial::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_list_partial(& payload("something_new")),
|
||||
Some(ConversationsPartial::Error)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn session_list_partial_absent_for_healthy_or_meta_less_responses() {
|
||||
let healthy = serde_json::json!(
|
||||
{ "sessions" : [], "_meta" : { "x.ai/partial" : { "conversations" : false } } }
|
||||
);
|
||||
assert_eq!(parse_session_list_partial(& healthy), None);
|
||||
let legacy = serde_json::json!({ "sessions" : [] });
|
||||
assert_eq!(parse_session_list_partial(& legacy), None);
|
||||
}
|
||||
/// The agent serializes `ExtMethodResult<KillTaskResponse>`: the outcome
|
||||
/// lives at `result.outcome`. Probing the top level (the pre-fix code)
|
||||
/// was why the tasks-pane ✗ never removed stale (`not_found`) rows after
|
||||
/// a session resume.
|
||||
#[test]
|
||||
fn parse_kill_outcome_reads_result_envelope() {
|
||||
use kigi_tools::types::KillOutcome;
|
||||
let resp = r#"{"result":{"taskId":"t-1","outcome":"not_found"}}"#;
|
||||
@@ -315,250 +277,61 @@ fn interject_params_carry_content_when_blocks_present() {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0] ["text"], "look at [Image #1]");
|
||||
}
|
||||
/// A billing config with every field unset, for use as a base in
|
||||
/// `credit_balance_from_config` tests via struct-update syntax.
|
||||
fn empty_billing_config() -> BillingConfig {
|
||||
BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: None,
|
||||
used: None,
|
||||
on_demand_cap: None,
|
||||
on_demand_used: None,
|
||||
prepaid_balance: None,
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![],
|
||||
}
|
||||
}
|
||||
/// `x.ai/billing` ext result (a serialized shell `UsageResponse`) parses
|
||||
/// into typed rows; unknown labels/reset hints survive the round trip.
|
||||
#[test]
|
||||
fn credit_balance_prefers_credit_usage_percent_over_limit_used() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(42.0),
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 9_000 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 42.0);
|
||||
fn parse_usage_response_reads_rows_from_fixture() {
|
||||
let fixture = serde_json::json!({
|
||||
"rows": [
|
||||
{
|
||||
"label": "Weekly limit",
|
||||
"used": 250,
|
||||
"limit": 1000,
|
||||
"resetHint": "resets in 2d 1h"
|
||||
},
|
||||
{ "label": "5h limit", "used": 20, "limit": 50 }
|
||||
]
|
||||
});
|
||||
let rows = parse_usage_response(&fixture).expect("fixture must parse");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].label, "Weekly limit");
|
||||
assert_eq!(rows[0].used, 250);
|
||||
assert_eq!(rows[0].limit, 1000);
|
||||
assert_eq!(rows[0].reset_hint.as_deref(), Some("resets in 2d 1h"));
|
||||
assert_eq!(rows[1].label, "5h limit");
|
||||
assert!(rows[1].reset_hint.is_none());
|
||||
}
|
||||
/// Round-trip through the shell's own serializer: what the billing
|
||||
/// extension emits must parse back to the same typed rows.
|
||||
#[test]
|
||||
fn credit_balance_forwards_is_unified_billing_user() {
|
||||
let c = BillingConfig {
|
||||
is_unified_billing_user: Some(true),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).is_unified_billing_user, Some(true));
|
||||
assert_eq!(
|
||||
credit_balance_from_config(empty_billing_config()).is_unified_billing_user, None
|
||||
);
|
||||
fn parse_usage_response_round_trips_shell_serialization() {
|
||||
use kigi_shell::extensions::billing::{UsageResponse, UsageRow};
|
||||
let wire = serde_json::to_value(&UsageResponse {
|
||||
rows: vec![UsageRow {
|
||||
label: "RPM".into(),
|
||||
used: 12,
|
||||
limit: 60,
|
||||
reset_hint: None,
|
||||
}],
|
||||
})
|
||||
.expect("serialize");
|
||||
let rows = parse_usage_response(&wire).expect("round trip");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].label, "RPM");
|
||||
assert_eq!(rows[0].used, 12);
|
||||
assert_eq!(rows[0].limit, 60);
|
||||
}
|
||||
/// Empty rows parse to an empty list (→ "No usage data available." in the
|
||||
/// dispatch layer); a malformed body is an error, not an empty quota list.
|
||||
#[test]
|
||||
fn credit_balance_falls_back_to_limit_used_when_percent_absent() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 2_500 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 25.0);
|
||||
}
|
||||
/// Match production: RFC 3339 → user's local wall-clock (no zone label).
|
||||
fn expected_period_end_display(rfc3339: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||
.expect("test fixture is valid RFC 3339")
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%B %-d, %H:%M")
|
||||
.to_string()
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_prefers_current_period_end_over_billing_period_end() {
|
||||
let end = "2026-06-08T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(10.0),
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()),
|
||||
start: Some("2026-06-01T00:00:00Z".into()),
|
||||
end: Some(end.into()),
|
||||
}),
|
||||
billing_period_end: Some("2026-07-01T20:00:00Z".into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_uses_local_timezone() {
|
||||
let winter = "2026-01-15T20:00:00Z";
|
||||
let summer = "2026-07-15T20:00:00Z";
|
||||
let winter_cfg = BillingConfig {
|
||||
billing_period_end: Some(winter.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let summer_cfg = BillingConfig {
|
||||
billing_period_end: Some(summer.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(winter_cfg).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(winter).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
credit_balance_from_config(summer_cfg).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(summer).as_str())
|
||||
);
|
||||
assert_ne!(expected_period_end_display(winter), expected_period_end_display(summer));
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_falls_back_to_billing_period_end() {
|
||||
let end = "2026-07-01T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
billing_period_end: Some(end.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_falls_back_when_current_period_has_no_end() {
|
||||
let end = "2026-07-01T20:00:00Z";
|
||||
let c = BillingConfig {
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: None,
|
||||
start: Some("2026-06-01T00:00:00Z".into()),
|
||||
end: None,
|
||||
}),
|
||||
billing_period_end: Some(end.into()),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(
|
||||
credit_balance_from_config(c).period_end_display.as_deref(),
|
||||
Some(expected_period_end_display(end).as_str())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_period_end_none_when_unavailable() {
|
||||
fn parse_usage_response_empty_and_malformed() {
|
||||
assert!(
|
||||
credit_balance_from_config(empty_billing_config()).period_end_display.is_none()
|
||||
parse_usage_response(&serde_json::json!({ "rows": [] }))
|
||||
.expect("empty rows parse")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_clamps_new_percent_above_100() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(150.0),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 100.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_clamps_legacy_used_above_limit() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 1_000 }),
|
||||
used: Some(Cent { val: 2_500 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
assert_eq!(credit_balance_from_config(c).usage_pct, 100.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_equals_usage_when_no_on_demand() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(40.0),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(! bal.pay_as_you_go);
|
||||
assert_eq!(bal.on_demand_cap_cents, None);
|
||||
assert_eq!(bal.effective_usage_pct, 40.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_uses_on_demand_ratio_when_included_exhausted() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(100.0),
|
||||
on_demand_cap: Some(Cent { val: 5_000 }),
|
||||
on_demand_used: Some(Cent { val: 1_000 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.usage_pct, 100.0);
|
||||
assert_eq!(bal.effective_usage_pct, 20.0);
|
||||
assert_eq!(bal.on_demand_cap_cents, Some(5_000));
|
||||
assert_eq!(bal.on_demand_used_cents, Some(1_000));
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_present_rule_resolves() {
|
||||
let v = serde_json::json!(
|
||||
{ "rule" : { "enabled" : true, "topupAmount" : { "val" : 2000 },
|
||||
"maxAmountPerMonth" : { "val" : 10000 } } }
|
||||
);
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(at.enabled);
|
||||
assert_eq!(at.topup_amount_cents, Some(2000));
|
||||
assert_eq!(at.max_amount_cents, Some(10000));
|
||||
}
|
||||
other => panic!("expected Resolved, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_empty_body_resolves_to_disabled() {
|
||||
for v in [serde_json::json!({}), serde_json::json!({ "rule" : null })] {
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(! at.enabled);
|
||||
}
|
||||
other => panic!("expected Resolved(disabled), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_rule_without_enabled_is_disabled() {
|
||||
let v = serde_json::json!({ "rule" : { "topupAmount" : { "val" : 500 } } });
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Resolved(at) => {
|
||||
assert!(! at.enabled);
|
||||
assert_eq!(at.topup_amount_cents, Some(500));
|
||||
}
|
||||
other => panic!("expected Resolved(disabled), got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_auto_topup_malformed_body_is_unchanged() {
|
||||
for v in [serde_json::json!(null), serde_json::json!(42)] {
|
||||
match parse_auto_topup_response(&v) {
|
||||
crate::views::credit_bar::AutoTopupFetch::Unchanged => {}
|
||||
other => panic!("expected Unchanged, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_tracks_included_for_new_shape_under_100() {
|
||||
let c = BillingConfig {
|
||||
credit_usage_percent: Some(95.0),
|
||||
on_demand_cap: Some(Cent { val: 5_000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.effective_usage_pct, 95.0);
|
||||
}
|
||||
#[test]
|
||||
fn credit_balance_effective_blends_budget_for_legacy_shape_under_100() {
|
||||
let c = BillingConfig {
|
||||
monthly_limit: Some(Cent { val: 10_000 }),
|
||||
used: Some(Cent { val: 5_000 }),
|
||||
on_demand_cap: Some(Cent { val: 10_000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
..empty_billing_config()
|
||||
};
|
||||
let bal = credit_balance_from_config(c);
|
||||
assert!(bal.pay_as_you_go);
|
||||
assert_eq!(bal.usage_pct, 50.0);
|
||||
assert_eq!(bal.effective_usage_pct, 25.0);
|
||||
assert!(parse_usage_response(&serde_json::json!({ "bogus": 1 })).is_err());
|
||||
assert!(parse_usage_response(&serde_json::json!(null)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn parse_worktree_restore_payload_full() {
|
||||
|
||||
Reference in new issue
Block a user