M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
//! Subscription tier checks, credit-limit upsells, and auto-topup handling.
|
||||
|
||||
use super::queue::maybe_drain_queue;
|
||||
use crate::app::actions::Effect;
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::AppView;
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long the pager auto-checks subscription status before stopping.
|
||||
/// After this, the user can still manually check via the [Refresh] button.
|
||||
pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// Whether the user is at the highest subscription tier (SuperGrok Heavy).
|
||||
///
|
||||
/// Returns `true` only when `subscription_tier` **positively matches** a
|
||||
/// known max-tier identifier. When the tier is unknown (`None`) or any
|
||||
/// other value, returns `false` — the user gets the Q&A modal so lower-
|
||||
/// tier users always see the upgrade option.
|
||||
pub(super) fn is_max_tier(subscription_tier: Option<&str>) -> bool {
|
||||
let Some(t) = subscription_tier else {
|
||||
return false; // Unknown — default to Q&A.
|
||||
};
|
||||
// Normalize: lowercase + spaces→underscores to match both JWT-derived
|
||||
// keys ("supergrok_heavy") and CCP display names ("SuperGrok Heavy").
|
||||
t.to_ascii_lowercase().replace(' ', "_") == "supergrok_heavy"
|
||||
}
|
||||
|
||||
/// URL for upgrading the subscription tier.
|
||||
pub(crate) const UPSELL_URL_UPGRADE: &str = "https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// URL for managing pay-as-you-go / on-demand spending / purchasing credits.
|
||||
pub(crate) const UPSELL_URL_PAYG: &str = "https://grok.com?_s=usage";
|
||||
|
||||
/// Billing mode for credit-limit upsell copy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum CreditLimitUpsellMode {
|
||||
/// Unified usage pool — suggest purchasing prepaid credits.
|
||||
UnifiedCredits,
|
||||
/// Legacy on-demand / PAYG (`enabled` = on-demand cap already active).
|
||||
LegacyPayg { enabled: bool },
|
||||
}
|
||||
|
||||
/// Resolve upsell copy mode from credits config.
|
||||
///
|
||||
/// Prefers explicit `is_unified_billing_user` (`Option` — do not treat a
|
||||
/// missing field as legacy). Positive `pay_as_you_go` (on-demand cap > 0)
|
||||
/// only selects legacy when the unified flag is absent. Unknown defaults to
|
||||
/// unified (buy credits) so pool users never get “enable on-demand” wrongly.
|
||||
pub(super) fn credit_limit_upsell_mode(
|
||||
balance: Option<&crate::views::credit_bar::CreditBalance>,
|
||||
) -> CreditLimitUpsellMode {
|
||||
match balance {
|
||||
Some(b) if b.is_unified_billing_user == Some(true) => CreditLimitUpsellMode::UnifiedCredits,
|
||||
Some(b) if b.is_unified_billing_user == Some(false) => CreditLimitUpsellMode::LegacyPayg {
|
||||
enabled: b.pay_as_you_go,
|
||||
},
|
||||
// Flag absent: only treat as legacy PAYG when we have a positive
|
||||
// on-demand cap (pay_as_you_go is derived from cap > 0).
|
||||
Some(b) if b.pay_as_you_go => CreditLimitUpsellMode::LegacyPayg { enabled: true },
|
||||
_ => CreditLimitUpsellMode::UnifiedCredits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an API / retry error is a credit-limit / spend-block denial.
|
||||
///
|
||||
/// - **402** Payment Required — always credit/spend block on this surface
|
||||
/// (Build pool and IC spend blocks); no message filter.
|
||||
/// - **403** — only when the body contains "run out of credits" (legacy IC
|
||||
/// spend wording); other 403s (content-safety, ZDR, …) are excluded.
|
||||
pub(crate) fn is_credit_limit_error(http_status: Option<u16>, message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
let legacy = m.contains("run out of credits");
|
||||
match http_status {
|
||||
Some(402) => true,
|
||||
Some(403) if legacy => true,
|
||||
// Retry notifications embed "status 402" / "status 403" in the body
|
||||
// without a separate status field.
|
||||
None | Some(_) => m.contains("status 402") || (m.contains("status 403") && legacy),
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known error code CCP returns (HTTP 429, flat body
|
||||
/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the
|
||||
/// free usage quota. Kept in sync with the shared well-known error code
|
||||
/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat
|
||||
/// `code` to the flattened message, so the code reaches the pager embedded
|
||||
/// in `RetryState::Exhausted.reason` and the -32003 error's data string.
|
||||
pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted";
|
||||
|
||||
/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall)
|
||||
/// rather than transient throttling. Text-sniff on the flattened message,
|
||||
/// same precedent as [`is_credit_limit_error`].
|
||||
pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool {
|
||||
reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE)
|
||||
}
|
||||
|
||||
/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion.
|
||||
/// `data` may be a bare string or the `{message, promptUsage?}` object
|
||||
/// `attach_prompt_usage` produces — always read via the shared detail helper.
|
||||
pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool {
|
||||
err.data
|
||||
.as_ref()
|
||||
.and_then(kigi_shell::sampling::error::error_detail_from_data)
|
||||
.as_deref()
|
||||
.is_some_and(is_free_usage_exhausted_error)
|
||||
}
|
||||
|
||||
/// User-facing message for free-usage exhaustion. Shown by headless mode and
|
||||
/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately
|
||||
/// promises no reset duration — the quota window is backend-config-driven.
|
||||
pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build";
|
||||
|
||||
/// Open the credit-limit upsell on the given agent.
|
||||
///
|
||||
/// **`max_tier = false`** (default): shows the Q&A question modal with
|
||||
/// two options ("Upgrade tier" + buy-credits or PAYG). Each option's `id`
|
||||
/// carries the target URL so the submit handler is position-independent.
|
||||
///
|
||||
/// **`max_tier = true`** (positively identified as SuperGrok Heavy):
|
||||
/// pushes an inline scrollback card (`CreditLimitBlock`) with a single
|
||||
/// continue action. No Q&A modal — the user can't upgrade further.
|
||||
pub(super) fn open_credit_limit_upsell(
|
||||
agent: &mut AgentView,
|
||||
mode: CreditLimitUpsellMode,
|
||||
max_tier: bool,
|
||||
) {
|
||||
use crate::scrollback::blocks::CreditLimitCardAction;
|
||||
|
||||
let (heading, upgrade_tier_desc, secondary_label, secondary_desc, card_action): (
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
CreditLimitCardAction,
|
||||
) = match mode {
|
||||
CreditLimitUpsellMode::UnifiedCredits => (
|
||||
"You hit your weekly limit.",
|
||||
"Upgrade to a higher tier for more usage",
|
||||
"Buy more credits",
|
||||
"Purchase credits to keep using Grok Build",
|
||||
CreditLimitCardAction::PurchaseCredits,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: true } => (
|
||||
"You\u{2019}ve hit your spending cap.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Increase limit",
|
||||
"Raise your pay-as-you-go spending cap",
|
||||
CreditLimitCardAction::IncreasePaygLimit,
|
||||
),
|
||||
CreditLimitUpsellMode::LegacyPayg { enabled: false } => (
|
||||
"You\u{2019}ve hit the credit limit for your plan.",
|
||||
"Upgrade to a higher tier for more credits",
|
||||
"Pay as you go",
|
||||
"Enable pay-as-you-go credits for on-demand usage",
|
||||
CreditLimitCardAction::EnablePayg,
|
||||
),
|
||||
};
|
||||
|
||||
// ── Max tier: inline scrollback card ─────────────────────────
|
||||
if max_tier {
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
agent.scrollback.push_block(RenderBlock::credit_limit_card(
|
||||
heading,
|
||||
card_action,
|
||||
UPSELL_URL_PAYG,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Default: Q&A question modal with two options ────────────────
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
if agent.question_view.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade tier".into(),
|
||||
description: upgrade_tier_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: secondary_label.into(),
|
||||
description: secondary_desc.into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_PAYG.into()),
|
||||
},
|
||||
],
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("credit-limit-upsell-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::CreditLimitUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
}
|
||||
|
||||
/// Open the free-usage paywall on the given agent: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style with two upgrade options. Each
|
||||
/// option's `id` carries its target URL so the submit handler is
|
||||
/// position-independent.
|
||||
///
|
||||
/// Driver-only by construction (called from the PromptResponse handler,
|
||||
/// which viewers never receive).
|
||||
pub(super) fn open_free_usage_upsell(agent: &mut AgentView) {
|
||||
open_supergrok_upsell(agent, UpsellReason::FreeUsageLimit);
|
||||
}
|
||||
|
||||
/// Open the SuperGrok upsell for a tier-restricted slash command
|
||||
/// (`/usage`, `/imagine`, …). Returns whether the modal opened (`false`
|
||||
/// when another question modal is already up) so the caller can decide
|
||||
/// whether to consume the input that triggered it.
|
||||
pub(super) fn open_restricted_command_upsell(agent: &mut AgentView) -> bool {
|
||||
open_supergrok_upsell(agent, UpsellReason::RestrictedCommand)
|
||||
}
|
||||
|
||||
/// Which situation opened the SuperGrok upsell modal. Controls the heading.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum UpsellReason {
|
||||
/// Free-usage quota exhausted (429 paywall).
|
||||
FreeUsageLimit,
|
||||
/// A tier-restricted slash command was invoked.
|
||||
RestrictedCommand,
|
||||
}
|
||||
|
||||
/// Shared builder behind [`open_free_usage_upsell`] /
|
||||
/// [`open_restricted_command_upsell`]: a Q&A modal in the
|
||||
/// [`open_credit_limit_upsell`] style. Upgrade options carry their target
|
||||
/// URL in the option `id` (position-independent submit handling).
|
||||
fn open_supergrok_upsell(agent: &mut AgentView, reason: UpsellReason) -> bool {
|
||||
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
|
||||
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
|
||||
|
||||
// Never displace an already-open question modal. Callers that consume
|
||||
// input on open must check this `false` and keep the input instead.
|
||||
if agent.question_view.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (heading, modal_id_prefix) = match reason {
|
||||
UpsellReason::FreeUsageLimit => ("You hit your free usage limit.", "free-usage-upsell"),
|
||||
UpsellReason::RestrictedCommand => (
|
||||
"Unlock all features with SuperGrok.",
|
||||
"restricted-command-upsell",
|
||||
),
|
||||
};
|
||||
|
||||
let options = vec![
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok".into(),
|
||||
description: "For everyday coding and productivity tasks".into(),
|
||||
preview: None,
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Upgrade to SuperGrok Heavy".into(),
|
||||
description: "Get the most out of Grok Build. Highest usage limits.".into(),
|
||||
preview: None,
|
||||
// No Heavy-specific URL exists; the /supergrok page lists
|
||||
// both plans, so both upgrade options land there.
|
||||
id: Some(UPSELL_URL_UPGRADE.into()),
|
||||
},
|
||||
];
|
||||
let question = Question {
|
||||
question: heading.into(),
|
||||
options,
|
||||
multi_select: Some(false),
|
||||
id: None,
|
||||
};
|
||||
|
||||
let stashed = agent.prompt.stash();
|
||||
let state = QuestionViewState::new(
|
||||
format!("{modal_id_prefix}-{}", uuid::Uuid::new_v4()),
|
||||
vec![question],
|
||||
stashed,
|
||||
)
|
||||
.with_local_kind(LocalQuestionKind::FreeUsageUpsell)
|
||||
.with_no_freeform();
|
||||
agent.question_view = Some(state);
|
||||
agent.prompt.set_text("");
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply an [`AutoTopupFetch`] outcome to a cached `auto_topup` slot: `Resolved`
|
||||
/// sets it, `Cleared` resets it to "unknown" (no credits), and `Unchanged` keeps
|
||||
/// the last-known-good value (the fetch failed).
|
||||
pub(super) fn apply_auto_topup(
|
||||
slot: &mut Option<crate::views::credit_bar::AutoTopupInfo>,
|
||||
fetch: &crate::views::credit_bar::AutoTopupFetch,
|
||||
) {
|
||||
use crate::views::credit_bar::AutoTopupFetch;
|
||||
match fetch {
|
||||
AutoTopupFetch::Resolved(rule) => *slot = Some(rule.clone()),
|
||||
AutoTopupFetch::Cleared => *slot = None,
|
||||
AutoTopupFetch::Unchanged => {}
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_billing_fetched(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
balance: Option<crate::views::credit_bar::CreditBalance>,
|
||||
silent: bool,
|
||||
subscription_tier: Option<String>,
|
||||
autotopup: crate::views::credit_bar::AutoTopupFetch,
|
||||
) -> Vec<Effect> {
|
||||
// Parse/transport failures route to `BillingError`, so a `None`
|
||||
// balance here means the response carried no billing config. Clear
|
||||
// the cached balance + polling so the status bar agrees with the
|
||||
// "No billing data available." message rather than showing a stale
|
||||
// value.
|
||||
app.credit_balance = balance.clone();
|
||||
// `Resolved` updates the cached rule, `Cleared` resets it to unknown
|
||||
// (no credits), `Unchanged` keeps the last-known-good (fetch failed).
|
||||
apply_auto_topup(&mut app.auto_topup, &autotopup);
|
||||
app.billing_poll_wanted = balance
|
||||
.as_ref()
|
||||
.map(|b| b.usage_pct >= 99.0)
|
||||
.unwrap_or(false);
|
||||
if let Some(tier) = subscription_tier {
|
||||
app.subscription_tier = Some(tier);
|
||||
}
|
||||
// Render the `/usage` summary from the now-current cached rule.
|
||||
let summary_topup = app.auto_topup.clone();
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
// Gateway/chat-kind: do not attach Build coding credits.
|
||||
let mut topup = agent.auto_topup.clone();
|
||||
apply_auto_topup(&mut topup, &autotopup);
|
||||
agent.apply_credit_balance(balance.clone(), topup);
|
||||
if !silent && !agent.chat_kind {
|
||||
let msg = match &balance {
|
||||
Some(bal) => {
|
||||
crate::views::credit_bar::format_usage_summary(bal, summary_topup.as_ref())
|
||||
}
|
||||
None => "No billing data available.".to_string(),
|
||||
};
|
||||
agent.scrollback.push_block(RenderBlock::System(
|
||||
crate::scrollback::blocks::SystemMessageBlock::new(msg),
|
||||
));
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_gate_refreshed(
|
||||
app: &mut AppView,
|
||||
settings: Option<kigi_shell::util::config::RemoteSettings>,
|
||||
) -> Vec<Effect> {
|
||||
let Some(rs) = settings else {
|
||||
return vec![];
|
||||
};
|
||||
app.usage_billing_redirect_url = rs.usage_billing_redirect_url.clone();
|
||||
if let Some(secs) = rs.subscription_watch_interval_secs {
|
||||
app.subscription_watch_interval_secs = Some(secs);
|
||||
}
|
||||
match AppView::gate_from_settings(&rs) {
|
||||
Some(gate) => app.impose_gate(gate),
|
||||
None => app.lift_gate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.ai/auth/check_subscription` completed. Meta is authoritative
|
||||
/// (`apply_auth_meta` also drops any deferred gate). A failed check only
|
||||
/// promotes the deferred gate it was verifying (`verify` generation);
|
||||
/// generic watch/focus/paywall-chain failures never touch it.
|
||||
pub(super) fn handle_check_subscription_complete(
|
||||
app: &mut AppView,
|
||||
verify: Option<u64>,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
let applied = match meta {
|
||||
Some(meta_val) => {
|
||||
match serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val) {
|
||||
Ok(auth_meta) => {
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Shell sent meta we can't decode — a protocol bug, not
|
||||
// a transient failure. The check result is lost, so a
|
||||
// verify deferral falls through to promotion below.
|
||||
crate::unified_log::error(
|
||||
"subscription.check.meta_parse_failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
// meta: None = shell reports "not authenticated" or the check RPC
|
||||
// failed (already logged as subscription.check.rpc_failed).
|
||||
None => false,
|
||||
};
|
||||
if !applied && let Some(generation) = verify {
|
||||
app.promote_deferred_gate(generation, "check_failed");
|
||||
}
|
||||
crate::unified_log::info(
|
||||
"subscription.check.complete",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"verify": verify,
|
||||
"meta_applied": applied,
|
||||
"was_blocked": was_blocked,
|
||||
"gated": !app.has_access(),
|
||||
"tier": app.subscription_tier,
|
||||
})),
|
||||
);
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Safety net for a hung verification check: show the still-pending
|
||||
/// deferred gate (err on blocking).
|
||||
pub(super) fn handle_gate_verify_timeout(app: &mut AppView, generation: u64) -> Vec<Effect> {
|
||||
let was_blocked = !app.has_access();
|
||||
app.promote_deferred_gate(generation, "verify_timeout");
|
||||
maybe_start_paywall_chain(app, was_blocked)
|
||||
}
|
||||
|
||||
/// Arm the 5s paywall auto-check chain on an ungated→gated transition, so a
|
||||
/// paywall shown by verify-before-paywall self-lifts exactly like the
|
||||
/// login-path one. Guarded so steady-state paywall-poller responses and
|
||||
/// repeated checks can't fan out extra timers.
|
||||
fn maybe_start_paywall_chain(app: &mut AppView, was_blocked: bool) -> Vec<Effect> {
|
||||
if !was_blocked && !app.has_access() && app.paywall_check_started.is_none() {
|
||||
app.paywall_check_started = Some(std::time::Instant::now());
|
||||
return vec![Effect::SchedulePaywallCheck];
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn handle_credit_limit_recheck_complete(
|
||||
app: &mut AppView,
|
||||
agent_id: AgentId,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Vec<Effect> {
|
||||
let old_tier = app.subscription_tier.clone();
|
||||
if let Some(meta_val) = meta
|
||||
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
|
||||
{
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
let tier_changed = app.subscription_tier != old_tier && app.subscription_tier.is_some();
|
||||
|
||||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// If the user already submitted another prompt while the
|
||||
// recheck was in flight, don't retry the stashed one — they've
|
||||
// moved on. The tier update (above) still takes effect.
|
||||
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
|
||||
|
||||
if tier_changed && !user_moved_on {
|
||||
if let Some(prompt) = agent.credit_limit_stashed_prompt.take() {
|
||||
let tier_name = app.subscription_tier.as_deref().unwrap_or("a higher tier");
|
||||
agent.scrollback.push_block(RenderBlock::system(format!(
|
||||
"Subscription upgraded to {tier_name}. Retrying\u{2026}"
|
||||
)));
|
||||
agent.session.enqueue_in_flight_prompt_front(prompt);
|
||||
}
|
||||
} else if !user_moved_on {
|
||||
let balance = agent
|
||||
.credit_balance
|
||||
.as_ref()
|
||||
.or(app.credit_balance.as_ref());
|
||||
let mode = credit_limit_upsell_mode(balance);
|
||||
let max_tier = is_max_tier(app.subscription_tier.as_deref());
|
||||
open_credit_limit_upsell(agent, mode, max_tier);
|
||||
}
|
||||
// Either way, drop the stashed prompt.
|
||||
agent.credit_limit_stashed_prompt = None;
|
||||
|
||||
let mut effects = maybe_drain_queue(agent);
|
||||
effects.push(Effect::FetchBilling {
|
||||
agent_id,
|
||||
silent: true,
|
||||
});
|
||||
effects
|
||||
}
|
||||
|
||||
// Action handlers.
|
||||
|
||||
pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
|
||||
let url = app
|
||||
.gate
|
||||
.as_ref()
|
||||
.and_then(|g| g.url.as_deref())
|
||||
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
|
||||
// Funnel attribution: tag CLI-originated SuperGrok upsell clicks
|
||||
// with `referrer=grok-build`, matching the OAuth consent flow and
|
||||
// x.ai/cli marketing links. Applied even when the URL came from
|
||||
// remote settings's `gate_url`, so we don't depend on the remote flag
|
||||
// being correctly configured. If the URL already specifies a
|
||||
// referrer it's left alone.
|
||||
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
|
||||
crate::app::link_opener::open_url(&url);
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn free_usage_dual_read_string_and_wrapped_object_data() {
|
||||
let free = "subscription:free-usage-exhausted quota hit";
|
||||
let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free);
|
||||
assert!(acp_error_is_free_usage_exhausted(&string_err));
|
||||
|
||||
// attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}.
|
||||
let wrapped =
|
||||
agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({
|
||||
"message": free,
|
||||
"promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 }
|
||||
}));
|
||||
assert!(acp_error_is_free_usage_exhausted(&wrapped));
|
||||
assert!(!wrapped.data.as_ref().unwrap().is_string());
|
||||
|
||||
let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled");
|
||||
assert!(!acp_error_is_free_usage_exhausted(&other));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user