Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
against kigi_env::oauth_host(); client_id per PRD; retryable statuses
429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
(macOS/Windows native backends), atomic-file fallback under ~/.kigi;
official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
401-tombstone keyed by rejected refresh token with 300s cooldown and
rotation auto-clear, cross-process lock with sibling-adoption
triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
external auth provider, JWT tier gating + subscription paywall stack,
X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
auth.kimi.com/api/oauth/device_authorization matches the wire shapes
Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
533 lines
21 KiB
Rust
533 lines
21 KiB
Rust
//! 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> {
|
|
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 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 show the upsell — they've moved on.
|
|
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
|
|
|
|
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));
|
|
}
|
|
}
|