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:
ZacharyZhang-NY committed 2026-07-17 16:05:51 -04:00
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
+8 -278
View File
@@ -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,
}
});
}
+49 -276
View File
@@ -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() {