// crates/atomcode-core/src/coding_plan/setup.rs
//
// Orchestrator for the 4-step CodingPlan flow. Single `run` entrypoint
// shared by the CLI subcommand and the TUI slash command; both render
// the returned `SetupReport` their own way (stdout vs. body scrollback).
//
// Failure policy (matches product spec D5):
//
// Step 1 Login — if not logged in and OAuth fails → bail out (nothing
// downstream works without a token).
// Step 2 Claim — `duplicate=true` means "already claimed / in review"
// — report it as a skip, NOT an error, and continue.
// Transport/5xx errors → bail (server is in a bad state).
// Step 3 Models — empty list or request failure → bail. The whole point
// of the flow is setting up providers; without models
// we have nothing to install.
// Step 4 Status — warn-only. The plan is already set up; a failed
// status fetch just means we can't show the quota
// widget. User can retry with `/codingplan` later.
//
// Provider mutation (D2 + D4):
//
// - All previously-created `AtomGit*` entries are wiped before inserts.
// Since CodingPlan is the authoritative source of truth for the
// model list, keeping stale names around would confuse `/model`.
// - Single model → one provider named `AtomGit`.
// - Multiple models → one provider per model, named
// `AtomGit-{display_model_name}` with `/` → `-` (keeps config.toml
// section names clean — `[providers.AtomGit-moonshotai-Kimi-K2]`).
// - `default_provider` is set to the first model in the API order.
use anyhow::Result;
use std::sync::Arc;
use super::client::{is_auth_expired, Client};
use super::types::{ModelEntry, PlanType, StatusResponse};
#[cfg(test)]
use super::types::RateLimitWindow;
use crate::auth;
use crate::config::provider::ProviderConfig;
use crate::config::Config;
/// Default LLM gateway base URL for CodingPlan-managed providers when
/// the `models-v2` payload doesn't carry a per-model `base_url`. Used
/// only inside [`codingplan_llm_base_url`] — call that, not this.
///
/// The new signed gateway. `coding_plan::crypto::is_atomgit_gateway`
/// **only** matches `pre-llm-api-cce.atomgit.com` (see the host whitelist at
/// `crypto.rs:129`), so this is the URL where codingplan request
/// signing actually engages. The previous default (the legacy
/// `api-ai.gitcode.com` host) silently routed new installs to a
/// plaintext path that bypassed signing — and surfaced in users'
/// error logs as "my requests go to a URL I never configured."
const DEFAULT_CODINGPLAN_LLM_BASE_URL: &str = "https://pre-llm-api-cce.atomgit.com/v1";
/// Resolve the LLM gateway base URL for CodingPlan-managed providers.
///
/// Read order:
/// 1. `ATOMCODE_CODINGPLAN_LLM_BASE_URL` env var (trimmed, trailing
/// `/` stripped, empty value treated as unset). Set this when
/// pointing the client at a staging gateway.
/// 2. [`DEFAULT_CODINGPLAN_LLM_BASE_URL`].
///
/// Cached once at first call via `OnceLock` — same shape as
/// [`auth::oauth::platform_base_url`] — so every provider registered
/// by `step_models_and_register` lands on the same host even if the
/// env var changes mid-flight, and the per-provider build cost is
/// one atomic read after the first call.
///
/// Returns `String` rather than `&'static str` because the cached
/// value's lifetime is tied to the `OnceLock`; callers that need an
/// owned URL (e.g. `ProviderConfig::base_url: Option<String>`) get
/// one without an extra clone.
fn codingplan_llm_base_url() -> String {
use std::sync::OnceLock;
static URL: OnceLock<String> = OnceLock::new();
URL.get_or_init(|| {
std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_CODINGPLAN_LLM_BASE_URL.to_string())
})
.clone()
}
/// Provider type for the AtomGit LLM gateway (it's OpenAI-compatible).
const PROVIDER_TYPE: &str = "openai";
/// Context window for each coding-plan provider. The models endpoint
/// doesn't currently return a per-model window, so we apply the same
/// 64k value that the legacy `/login` flow hard-coded.
const CONTEXT_WINDOW: usize = 64_000;
/// Prefix used for every coding-plan-managed provider name.
const PROVIDER_PREFIX: &str = "AtomGit";
/// Result of one orchestrator step. Distinct from `Result` because
/// "already done / idempotent skip" is a first-class outcome, not an
/// error — the report needs to tell the user "you already claimed this
/// last week" in the same place it'd tell them "just claimed".
#[derive(Debug, Clone)]
pub enum StepResult<T> {
/// Step ran and completed with the carried payload.
Ok(T),
/// Step was idempotent-skipped (already logged in, already claimed).
/// The string is a human-readable reason for display.
Skipped(String),
/// Step failed. The string is a human-readable error.
Err(String),
}
impl<T> StepResult<T> {
pub fn is_err(&self) -> bool {
matches!(self, StepResult::Err(_))
}
pub fn is_ok_or_skipped(&self) -> bool {
!self.is_err()
}
}
/// Describes how the auto-detected vision_preprocessor_provider was
/// (or was not) updated by `step_models_and_register`. Surfaces in
/// `SetupReport::render` so the user can see what happened to that
/// config knob across the /codingplan flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VisionPreprocessorOutcome {
/// Field was None and remains None (no VL/OCR in list).
UnchangedNone,
/// Field was a non-AtomGit user-supplied value; preserved.
/// Carries the value for display.
UserSupplied(String),
/// Field was None or a stale AtomGit-* key; auto-pointed at a
/// vision-capable provider in the freshly-installed list.
/// Carries the new key.
AutoSet(String),
/// Field was an AtomGit-* key but the new list has no VL/OCR
/// candidate, so the field was cleared to None to avoid pointing
/// at a wiped provider key.
Cleared,
}
impl SetupReport {
/// Render as a multi-line plain-text block for stdout / TUI body.
/// Shared by the CLI subcommand and the `/codingplan` slash command
/// so the visual contract stays consistent.
pub fn render(&self) -> String {
use crate::i18n::{t, Msg};
let mut out = String::new();
out.push_str(&t(Msg::CpSetupHeader));
// Step 1: login
match &self.login {
StepResult::Ok(info) => {
let who = info.display_name.as_deref().unwrap_or(&info.username);
let email = info.email.as_deref().unwrap_or("—");
out.push_str(&t(Msg::CpLoggedIn {
who,
username: &info.username,
email,
}));
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpStepSkipped { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpLoginFailed { error: msg }));
}
}
// Step 2: claim. When `claim_attempts` is populated (production
// path), emit one row per *successful* tier — refused / errored
// intermediates (e.g. "Max 套餐尚未开放") are suppressed so the
// happy-path output collapses to a single "Pro 生效" line. If
// no tier succeeded, fall through to a single failure row using
// `self.claim` so the user still gets a signal. When empty
// (login-cascade suppression OR legacy test fixtures), fall
// back to the legacy single-row path.
if !self.claim_attempts.is_empty() {
let mut any_success = false;
for attempt in &self.claim_attempts {
let tier = attempt.tier.as_str();
match &attempt.outcome {
TierOutcome::Claimed { .. } => {
out.push_str(&t(Msg::CpClaimTierSucceeded { tier }));
any_success = true;
}
TierOutcome::AlreadyHeld { .. } => {
out.push_str(&t(Msg::CpClaimTierAlreadyHeld { tier }));
any_success = true;
}
TierOutcome::Refused { .. } | TierOutcome::Errored { .. } => {
// Silently skipped — failures in the
// tier cascade are noise once any other tier
// succeeds, and even an all-fail run gets one
// consolidated row below instead of three.
}
}
}
if !any_success {
if let StepResult::Err(msg) = &self.claim {
// Pick the freshest non-empty source for the body:
// overall Err first, then walk attempts in reverse
// so the last tier's server message wins. Avoids a
// dangling `— ` when the overall Err was scrubbed
// empty by the cascade builder.
let body = if !msg.is_empty() {
msg.clone()
} else {
self.claim_attempts
.iter()
.rev()
.find_map(|a| match &a.outcome {
TierOutcome::Refused { message } if !message.is_empty() => {
Some(format!("{}: {}", a.tier.as_str(), message))
}
TierOutcome::Errored { error } if !error.is_empty() => {
Some(format!("{}: {}", a.tier.as_str(), error))
}
_ => None,
})
.unwrap_or_default()
};
if body.is_empty() {
// Truly nothing to say — render the prefix
// without a trailing em-dash body. Edge case:
// every tier responded success=false with no
// message AND no error text.
out.push_str(&t(Msg::CpClaimFailedBare));
} else {
out.push_str(&t(Msg::CpClaimFailed {
error: &truncate_inline(&body, 150),
}));
}
}
}
} else {
match &self.claim {
StepResult::Ok(info) => {
let fallback = t(Msg::CpClaimSuccessFallback);
let message = if info.message.is_empty() {
fallback.as_ref()
} else {
info.message.as_str()
};
out.push_str(&t(Msg::CpClaimed {
message,
plan_type: info.plan_type.as_str(),
}));
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
// Cascade from login failure — suppressed.
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpAlreadyClaimed { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpClaimFailed { error: msg }));
}
}
}
// Step 3: models. When the cascade marker is present (claim
// failed upstream), skip the row entirely — printing
// "Models step skipped — claim failed" right after the claim
// failure line is just noise. Same for the status row below.
match &self.models {
StepResult::Ok(info) => {
let count = info.provider_names.len();
let plural_s = if count == 1 { "" } else { "s" };
out.push_str(&t(Msg::CpAddedProviders { count, plural_s }));
// Build a quick lookup of which display names made it
// into the registered provider list — anything in
// `all_models` but NOT in this set is locked behind
// the user's plan tier.
let registered: std::collections::HashSet<&str> =
info.display_names.iter().map(|s| s.as_str()).collect();
// Locked models render FIRST so the upgrade prompt is the
// first thing the eye lands on under "Added N providers:".
// Visual cue is an `×` prefix matching the existing
// failure rows (`× CodingPlan Max claim failed — …`)
// plus the explicit `(requires Pro plan or higher)` suffix —
// both plain text, so every renderer (alt-screen /
// retained / plain) and every terminal font carries
// the meaning. An earlier U+0336 combining strikethrough
// pass was dropped after a user report that fonts in
// the wild silently skip the overlay glyph; the SGR 9
// approach before that was eaten by the TUI's CSI
// sanitizer (`tuix::sanitize::scrub_controls`). The
// prefix-plus-suffix combo doesn't depend on either.
let locked: Vec<&ModelEntry> = info
.all_models
.iter()
.filter(|m| !m.plan_available && !registered.contains(m.display_model_name.as_str()))
.collect();
for m in &locked {
out.push_str(&t(Msg::CpLocked {
name: &m.display_model_name,
}));
}
let default_suffix_cow = t(Msg::CpDefaultSuffix);
for (pname, model) in info.provider_names.iter().zip(info.display_names.iter()) {
let suffix = if pname == &info.default_provider {
default_suffix_cow.as_ref()
} else {
""
};
out.push_str(&t(Msg::CpProviderRow {
provider: pname,
model,
default_suffix: suffix,
}));
}
// Vision-preprocessor outcome line.
match &info.vision_preprocessor {
VisionPreprocessorOutcome::AutoSet(k) => {
out.push_str(&t(Msg::CpVisionAuto { kind: k }));
}
VisionPreprocessorOutcome::UserSupplied(k) => {
out.push_str(&t(Msg::CpVisionUserSupplied { kind: k }));
}
VisionPreprocessorOutcome::Cleared => {
out.push_str(&t(Msg::CpVisionCleared));
}
VisionPreprocessorOutcome::UnchangedNone => {
// No-op: nothing to say when both the previous and
// new state are "no preprocessor configured".
}
}
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
// Suppress — claim failure line above is the explanation.
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpModelsSkipped { reason }));
}
StepResult::Err(msg) => {
out.push_str(&t(Msg::CpModelsFailed { error: msg }));
}
}
// Step 4: status
match &self.status {
StepResult::Ok(s) => {
out.push_str(&t(Msg::CpStatusHeader));
if let Some(plan) = &s.codingplan_free {
if plan.expires_at.is_empty() {
// Backend sends null claimed_at/expires_at while a
// fresh claim is still propagating. Don't render an
// empty date with `(0d / 0d remaining)` zeros — say
// "pending activation" so the user knows to wait.
out.push_str(&t(Msg::CpPlanPending { plan: &plan.plan_name }));
} else {
out.push_str(&t(Msg::CpPlanActive {
plan: &plan.plan_name,
expires_at: &plan.expires_at,
remaining_days: plan.remaining_days,
total_days: plan.total_days,
}));
}
}
if !s.rate_limit_windows.is_empty() {
// An exhausted long window (>5h, typically the 30d monthly
// quota) means the user has hit the longer-period limit.
// In that state the short 5h rolling window is moot —
// even if it reads `0% · 重置于 2h 后`, the request
// path is still gated by the monthly cap, so the user
// can't actually issue calls until the long window
// resets. The server hides that monthly window via
// `show_enable=0` while still flagging `quota_exhausted`,
// so we detect exhaustion by that flag (see
// `blocking_exhausted_window`) and render only its line;
// otherwise iterate visible short windows normally.
if let Some(w) = blocking_exhausted_window(&s.rate_limit_windows) {
out.push_str(&t(Msg::CpMonthlyQuotaExhausted {
duration: &format_duration_secs(w.seconds_until_reset),
}));
} else {
for w in s.rate_limit_windows.iter().filter(|w| w.show_enable == 1) {
// All visible windows are short rolling (≤5h) —
// standard usage line.
out.push_str(&t(Msg::CpUsageLine {
usage: &w.usage_status_desc,
reset_at: &w.reset_at_display,
duration: &format_duration_secs(w.seconds_until_reset),
}));
}
}
} else {
// Backward-compat path for old server responses.
//
// `window_quota_exhausted=true` and `current_usage` are
// independent fields on the legacy response, and the
// server can — and visibly does — set BOTH simultaneously
// (typically `current_usage.usage_status_desc=0%` for a
// freshly-reset short window plus the exhaustion flag for
// a separately-tracked longer window). Rendering both
// produces the contradictory `用量 0% / ⚠额度已满` pair
// the user reported as "v4.23.2 怎么还是这么展示". When
// both fire, the user-actionable message is the
// exhaustion warning; the usage line at 0% reads as
// "you're fine" and just confuses things. Surface the
// warning alone — same precedence the new
// `rate_limit_windows` path already encodes (it picks
// exactly one row per visible window).
if s.window_quota_exhausted {
if let Some(hint) = &s.window_quota_hint {
out.push_str(&t(Msg::CpWindowQuotaHint { hint }));
} else {
out.push_str(&t(Msg::CpWindowQuotaExhausted));
}
} else if let Some(u) = &s.current_usage {
out.push_str(&t(Msg::CpUsageLine {
usage: &u.display_desc(),
reset_at: &u.reset_at_display,
duration: &format_duration_secs(u.seconds_until_reset),
}));
}
}
}
StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
// Suppress — cascade from claim failure.
}
StepResult::Skipped(reason) => {
out.push_str(&t(Msg::CpStatusFetchSkipped { reason }));
}
StepResult::Err(msg) => {
// Truncate the error chain so a server-side parse failure
// doesn't dump the entire response body inline. The cause
// chain commonly includes the raw JSON via anyhow's
// `with_context(format!("(body: {})", body))`, easily
// 200+ chars; the diagnostic value beyond ~150 is low.
out.push_str(&t(Msg::CpStatusFetchFailed {
error: &truncate_inline(msg, 150),
}));
}
}
out
}
/// True iff every persist-relevant step (login + claim + models)
/// either succeeded outright or was skipped non-fatally (server
/// reported `duplicate=true`, model list already current, etc.).
/// Callers use this to decide whether to persist config changes
/// to disk.
///
/// `claim` MUST be in the predicate: when claim returns `Err`
/// (e.g. backend 500 like the AtomGit `claim-v2` transaction-
/// rollback bug), `run()` short-circuits and parks `models` as
/// `Skipped(CASCADE_FROM_UPSTREAM_FAIL)` so the report stays
/// focused on the actual failure. Without the claim check the
/// gate flipped to `true` on every claim-failure path —
/// triggering `save_and_reload` to rewrite `config.toml`
/// unconditionally. That clobbered any manual edits the user
/// made between TUI startup and `/codingplan`, and read as
/// "claim failed but it still wrote models to my config".
pub fn should_persist_config(&self) -> bool {
self.login.is_ok_or_skipped()
&& self.claim.is_ok_or_skipped()
&& self.models.is_ok_or_skipped()
}
}
/// Display-friendly summary of each step's outcome. Returned by `run`
/// so the caller can render however it wants (plain stdout, TUI body
/// scrollback, future JSON output for scripting).
#[derive(Debug, Clone)]
pub struct SetupReport {
pub login: StepResult<LoginInfo>,
pub claim: StepResult<ClaimInfo>,
/// Per-tier cascade history. Populated by `step_claim` with one
/// entry per tier actually attempted (in cascade order Max → Pro
/// → Lite). Empty when the cascade never ran (e.g. login failed
/// upstream — claim is `Skipped(CASCADE_FROM_UPSTREAM_FAIL)`) or
/// when a legacy test fixture wants the old single-row claim
/// summary. `render` walks this to emit one row per tier so
/// refused / errored intermediate tiers are visible, not hidden
/// behind a single "claim failed" summary.
pub claim_attempts: Vec<TierAttempt>,
pub models: StepResult<ModelsInfo>,
pub status: StepResult<StatusResponse>,
/// True when any API call rejected the stored bearer token
/// (401/403). `is_logged_in()` only checks "does auth.toml exist"
/// and `get_valid_token` only refreshes when the recorded
/// `expires_in` says so — neither catches a server-side revocation
/// or a refresh-token that the broker no longer accepts. Shells
/// (TUI `/codingplan`, CLI `atomcode codingplan`) read this flag
/// to drive an inline re-OAuth + retry instead of leaving the user
/// staring at a "claim failed — run `atomcode login` again" line
/// when `/login` would have fixed it in one step.
pub auth_expired: bool,
}
#[derive(Debug, Clone)]
pub struct LoginInfo {
pub username: String,
pub display_name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ClaimInfo {
pub message: String,
/// true when server reported `duplicate=true` — surfaces in the
/// rendered report as "(already claimed)" rather than "(just claimed)".
pub duplicate: bool,
/// The CodingPlan tier the cascade landed on. `Max` if the
/// highest-tier claim succeeded, `Pro` / `Lite` for fallbacks.
/// Threaded into `step_models_and_register` as the `?plan_type=`
/// argument so the model list comes back with availability gated
/// to the user's actual entitlement.
pub plan_type: PlanType,
}
/// Per-tier outcome captured while `step_claim` walks the cascade.
/// Surfaces in `SetupReport::render` as one row per attempted tier so
/// the user can see exactly why the cascade stopped where it did — a
/// single "claim failed: Lite: 暂无开放" line hid the Max / Pro tier
/// rejections users wanted to see.
#[derive(Debug, Clone)]
pub enum TierOutcome {
/// `success=true` on this tier — cascade winner.
Claimed { message: String },
/// `duplicate=true` — user already held this (or a higher) tier;
/// cascade treats this as winner and stops.
AlreadyHeld { message: String },
/// `2xx success=false duplicate=false` — per-tier refusal (e.g.
/// `额度已满` / `暂无开放`). Cascade walks past to the next tier.
Refused { message: String },
/// Transport / 5xx / parse failure. Cascade aborts.
Errored { error: String },
}
#[derive(Debug, Clone)]
pub struct TierAttempt {
pub tier: PlanType,
pub outcome: TierOutcome,
}
#[derive(Debug, Clone)]
pub struct ModelsInfo {
/// Model names of the **available** subset, in server order.
/// Parallel to `provider_names` — these are the entries that
/// actually got registered as providers.
pub display_names: Vec<String>,
/// Provider keys actually inserted into Config (available only).
pub provider_names: Vec<String>,
/// Which of `provider_names` was set as `default_provider`.
pub default_provider: String,
/// Outcome of vision_preprocessor_provider auto-config. Drives the
/// "Vision preprocessor → ..." line in the rendered report.
pub vision_preprocessor: VisionPreprocessorOutcome,
/// Full v2 model list — including `plan_available=false` entries
/// that we didn't register as providers. Renderer iterates this
/// to show locked models with strikethrough so users see what
/// upgrading the plan would unlock.
pub all_models: Vec<ModelEntry>,
}
/// Entry point. Mutates `config` in place (providers + default_provider);
/// the caller is responsible for persisting it to disk after a successful
/// run. This keeps the core free of I/O concerns — tests can call `run`
/// against a `Config::default()` without touching the filesystem.
///
/// Emits exactly one `TakeCodingplan { Success | Fail }` event at each exit path.
pub fn run(
config: &mut Config,
tel: Option<&Arc<atomcode_telemetry::Telemetry>>,
) -> Result<SetupReport> {
// Step 1: login
let login = step_login(tel);
if login.is_err() {
// No point continuing — every downstream call needs a token.
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Fail,
error_kind: Some(atomcode_telemetry::CodingplanErrorKind::AuthError),
error_data: Some(serde_json::json!({
"step": "login",
"message": "Not logged in",
}).to_string()),
});
}
// Use the cascade sentinel so format() suppresses the three
// "Foo failed — skipped: login failed" rows that used to spam
// the report. The login-failure line above is the only thing
// worth showing; the rest is implied.
return Ok(SetupReport {
login,
claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
});
}
// Step 2: claim — cascade Max → Pro → Lite, first success wins.
let (claim, claim_attempts, claim_auth_expired) = step_claim();
if claim.is_err() {
// Claim failed at every tier — adding providers / fetching
// status both make no sense without an active plan. Bail
// with cascade markers for models/status; `claim_attempts`
// still carries every tier's outcome so the renderer shows
// the per-tier rows that explain WHY the cascade gave up.
return Ok(SetupReport {
login,
claim,
claim_attempts,
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: claim_auth_expired,
});
}
// Decide the plan_type to send to /models-v2. Three sources:
// * Fresh `Ok` claim: use the tier the cascade landed on.
// * `Skipped` (server returned `duplicate=true` at one of the
// tiers): step_claim picked the tier it stopped at; we don't
// have the structured value here, so fall back to Max — the
// server will gate availability the same way regardless. Pro
// and Lite users will see Pro/Max-tier models marked
// `plan_available=false` and rendered with strikethrough,
// which matches the spec ("show locked models too").
// * (Err is unreachable here — handled above.)
let plan_type_for_models = match &claim {
StepResult::Ok(info) => info.plan_type,
_ => PlanType::Max,
};
// Step 3: models — critical. Without models there's nothing to set up.
let (models, models_auth_expired) = step_models_and_register(config, plan_type_for_models);
if models.is_err() {
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Fail,
error_kind: Some(atomcode_telemetry::CodingplanErrorKind::NetworkError),
error_data: Some(serde_json::json!({
"step": "models",
"message": "Failed to fetch model list",
}).to_string()),
});
}
// Same cascade pattern: the models-failure line above is the
// explanation; "Status fetch failed — skipped: models step
// failed" adds nothing.
return Ok(SetupReport {
login,
claim,
claim_attempts,
models,
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: models_auth_expired,
});
}
// Step 4: status — warn-only. A 401 here is rare (claim+models
// both passed) but still worth surfacing so a retry has a chance
// to capture the warm token.
let (status, status_auth_expired) = step_status();
// All critical steps (login + models) succeeded. Emit success event.
if let Some(t) = tel {
t.track(atomcode_telemetry::Event::TakeCodingplan {
type_: atomcode_telemetry::CodingplanResult::Success,
error_kind: None,
error_data: Some(serde_json::json!({
"step": null,
}).to_string()),
});
}
Ok(SetupReport {
login,
claim,
claim_attempts,
models,
status,
auth_expired: status_auth_expired,
})
}
/// Sentinel reason used when downstream steps are skipped because an
/// earlier required step failed (login / claim / models). `format()`
/// recognises this exact string and renders nothing — the upstream
/// failure line above already explains why nothing came after it.
const CASCADE_FROM_UPSTREAM_FAIL: &str = "__cascade_upstream_fail__";
fn step_login(tel: Option<&Arc<atomcode_telemetry::Telemetry>>) -> StepResult<LoginInfo> {
if auth::is_logged_in() {
// Already authed — surface the stored identity so the report
// shows *who* we're running as, not a bare "skipped". When
// display-name and username differ (the common case), show
// both so the user can tell them apart: `TheoCui(saulcy)`.
if let Some(info) = auth::get_stored_auth() {
let display = match info.user.name.as_deref() {
Some(name) if !name.is_empty() && name != info.user.username => {
format!("{}({})", name, info.user.username)
}
_ => info.user.username.clone(),
};
return StepResult::Skipped(format!("already logged in as {}", display));
}
// Weird: is_logged_in said yes but stored auth is None. Treat
// as "login succeeded, details unavailable" rather than failing.
return StepResult::Skipped("already logged in".into());
}
// Not logged in — run OAuth. This prints to stdout + opens a browser.
// Callers in TUI context must have already suspended raw mode before
// calling `run`.
match auth::login(tel).and_then(|a| auth::save_auth(&a).map(|_| a)) {
Ok(auth_info) => StepResult::Ok(LoginInfo {
username: auth_info.user.username.clone(),
display_name: auth_info.user.name.clone(),
email: auth_info.user.email.clone(),
}),
Err(e) => StepResult::Err(format!("login failed: {:#}", e)),
}
}
/// Walk `PlanType::CASCADE_ORDER` (Max → Pro → Lite), POSTing
/// `claim-v2` for each tier, and stop at the first that lands the
/// user with an entitlement. Two outcomes count as "stop":
///
/// * `success=true` — fresh claim of this tier.
/// * `duplicate=true` — user already holds this tier (or
/// higher). Treat as success and use
/// this tier as the working tier;
/// trying lower tiers wouldn't help.
///
/// `success=false && duplicate=false` for a 2xx response is a per-tier
/// "you can't have this" signal (e.g. quota exhausted at the Max tier
/// but Pro/Lite slots still open). Try the next tier with the message
/// preserved as the "last error" we'll show if everything below also
/// fails.
///
/// Transport / 5xx errors abort the whole cascade — those mean the
/// server is in a bad state, not "this tier is unavailable", so
/// retrying lower tiers would just stack identical failures.
/// Walk the cascade and capture every tier's outcome.
///
/// Returns `(overall, attempts, auth_expired)`:
/// * `overall` — the legacy single-summary view of what happened
/// (`Ok` / `Skipped` / `Err`). Drives `should_persist_config` and
/// the downstream `step_models_and_register` plan-type selection.
/// * `attempts` — every tier actually attempted, in cascade order.
/// Renderer walks this to emit one row per tier (refused /
/// errored / claimed) so users can see the full picture instead
/// of just the winner.
/// * `auth_expired` — true iff the failure was a 401/403 from
/// `claim-v2` (or a `from_stored_auth` refresh failure). Bubbled
/// up to `SetupReport.auth_expired` so the shell knows to
/// re-OAuth and retry instead of just printing the failure.
fn step_claim() -> (StepResult<ClaimInfo>, Vec<TierAttempt>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
Vec::new(),
auth_expired,
);
}
};
let mut attempts: Vec<TierAttempt> = Vec::with_capacity(PlanType::CASCADE_ORDER.len());
let mut last_msg = String::new();
for &tier in PlanType::CASCADE_ORDER {
match client.claim_v2(tier) {
Ok(resp) => {
if resp.duplicate {
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::AlreadyHeld {
message: resp.message.clone(),
},
});
let skipped = StepResult::Skipped(if resp.message.is_empty() {
format!(
"already claimed (or under review) — using {}",
tier.as_str()
)
} else {
format!("{} ({})", resp.message, tier.as_str())
});
return (skipped, attempts, false);
}
if resp.success {
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Claimed {
message: resp.message.clone(),
},
});
let ok = StepResult::Ok(ClaimInfo {
message: if resp.message.is_empty() {
format!("claimed {}", tier.as_str())
} else {
resp.message
},
duplicate: false,
plan_type: tier,
});
return (ok, attempts, false);
}
// 2xx + success=false + duplicate=false: per-tier
// refusal (quota / not eligible / 暂无开放).
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Refused {
message: resp.message.clone(),
},
});
last_msg = if resp.message.is_empty() {
format!("{} claim refused", tier.as_str())
} else {
format!("{}: {}", tier.as_str(), resp.message)
};
}
Err(e) => {
// Transport / 5xx / parse failure — bail. These don't
// get more useful when retried at a lower tier. Capture
// the auth-expired bit BEFORE flattening `e` to a string
// so the shell layer can retry with a fresh OAuth.
let auth_expired = is_auth_expired(&e);
let err_text = format!("{:#}", e);
attempts.push(TierAttempt {
tier,
outcome: TierOutcome::Errored {
error: err_text.clone(),
},
});
return (
StepResult::Err(format!("claim {} request: {}", tier.as_str(), err_text)),
attempts,
auth_expired,
);
}
}
}
// Surface the server's last response message verbatim — already in
// the user's language. The render layer wraps it in a localized
// prefix (`× CodingPlan 套餐配置失败 — …`), so a hardcoded English
// diagnostic wrapper here ("claim failed at every tier — …") would
// bleed into zh-CN output. Empty → empty; render guards the dangling
// em-dash.
let overall = StepResult::Err(last_msg.clone());
(overall, attempts, false)
}
fn step_models_and_register(
config: &mut Config,
plan_type: PlanType,
) -> (StepResult<ModelsInfo>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
auth_expired,
);
}
};
let all_models = match client.list_models_v2(plan_type) {
Ok(v) => v,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("list models-v2: {:#}", e)),
auth_expired,
);
}
};
if all_models.is_empty() {
return (
StepResult::Err(
"server returned an empty model list — cannot set up any provider".into(),
),
false,
);
}
// Available subset — only these become providers. Locked ones
// (`plan_available=false`) survive in `all_models` for the
// strikethrough-display path; registering them as providers would
// give the user something they can `/model` into that 403s on the
// first request.
let available: Vec<&ModelEntry> = all_models.iter().filter(|m| m.plan_available).collect();
if available.is_empty() {
return (
StepResult::Err(format!(
"no models available on plan {} — server returned {} locked entries",
plan_type.as_str(),
all_models.len()
)),
false,
);
}
// Wipe any stale AtomGit* entries so we don't accumulate old names.
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let names: Vec<String> = available
.iter()
.map(|m| m.display_model_name.clone())
.collect();
let provider_names = provider_names_for(&names);
let default_provider = provider_names
.first()
.cloned()
.unwrap_or_else(|| PROVIDER_PREFIX.to_string());
for (pname, m) in provider_names.iter().zip(available.iter()) {
let pc = build_codingplan_provider(m);
config.providers.insert(pname.clone(), pc);
}
config.default_provider = default_provider.clone();
// Auto-detect a vision_preprocessor candidate from the freshly
// installed list. Precedence:
// - User-supplied non-AtomGit value: leave alone.
// - None / AtomGit-* (i.e. previous /codingplan run): replace
// with first VL/OCR model's provider key from the new list,
// or clear to None when the new list has no VL candidate.
let vl_idx = names
.iter()
.position(|n| crate::provider::model_name_suggests_vision(n));
let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
let vision_preprocessor = {
let current = config.vision_preprocessor_provider.clone();
let user_supplied_non_atomgit = current
.as_deref()
.map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
.unwrap_or(false);
if user_supplied_non_atomgit {
VisionPreprocessorOutcome::UserSupplied(current.unwrap())
} else {
match new_vl_key {
Some(k) => {
config.vision_preprocessor_provider = Some(k.clone());
VisionPreprocessorOutcome::AutoSet(k)
}
None => {
if current.is_some() {
config.vision_preprocessor_provider = None;
VisionPreprocessorOutcome::Cleared
} else {
VisionPreprocessorOutcome::UnchangedNone
}
}
}
}
};
(
StepResult::Ok(ModelsInfo {
display_names: names,
provider_names,
default_provider,
vision_preprocessor,
all_models,
}),
false,
)
}
fn step_status() -> (StepResult<StatusResponse>, bool) {
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(e) => {
let auth_expired = is_auth_expired(&e);
return (
StepResult::Err(format!("build client: {:#}", e)),
auth_expired,
);
}
};
match client.status_v2() {
Ok(s) => (StepResult::Ok(s), false),
Err(e) => {
let auth_expired = is_auth_expired(&e);
(
StepResult::Err(format!("status-v2: {:#}", e)),
auth_expired,
)
}
}
}
/// Truncate a single-line message to at most `max` chars, appending `…`
/// when shortened. Char-boundary safe (won't split a UTF-8 codepoint).
/// Used when rendering error messages whose source includes a server
/// response body — useful diagnostic prefix, useless multi-KB tail.
fn truncate_inline(msg: &str, max: usize) -> String {
if msg.chars().count() <= max {
return msg.to_string();
}
let mut out: String = msg.chars().take(max).collect();
out.push('…');
out
}
/// Format a duration in seconds as a short human-readable label —
/// `90s`, `5m`, `2h 30m`, `3d 4h`. Replaces the previous "{N}s" which
/// was unreadable for anything past a minute (e.g. "in 86340s" instead
/// of "in 23h 59m").
///
/// Pick the rate-limit window that is *actually blocking* the user, if any.
///
/// The server reports an exhausted longer-period quota (e.g. the 30d monthly
/// cap) with `quota_exhausted=true`, but it sets that window's `show_enable=0`
/// (it hides the raw "本月 100%" line) while leaving the short 5h rolling
/// window visible at a misleading `0%`. So exhaustion MUST be detected via
/// `quota_exhausted`, never via `show_enable` (the old filter keyed on
/// `show_enable==1` and missed the hidden monthly window entirely — surfacing
/// the rolling window's false "用量约 0%" instead of "本月已耗尽").
///
/// Restricted to windows longer than 5h so the caller's "monthly" wording
/// stays accurate. Returns the longest such window when several are exhausted.
///
/// `pub` so `/login` (here) and `/status` (atomcode-tuix) share one rule.
pub fn blocking_exhausted_window(
windows: &[crate::coding_plan::types::RateLimitWindow],
) -> Option<&crate::coding_plan::types::RateLimitWindow> {
windows
.iter()
.filter(|w| w.quota_exhausted && w.window_size_seconds / 3600 > 5)
.max_by_key(|w| w.window_size_seconds)
}
/// `pub` so the `/status` rendering in atomcode-tuix can share the same
/// formatter — keeps the `用量 重置于 ...(2h 后)` line consistent
/// between `/login`'s CodingPlan setup output and `/status`'s
/// CodingPlan section. (Pre-fix they diverged: setup said `2h`, status
/// said `5984s`.)
pub fn format_duration_secs(secs: i64) -> String {
if secs < 0 {
return "—".into();
}
let s = secs as u64;
if s < 60 {
return format!("{}s", s);
}
let (m, sr) = (s / 60, s % 60);
if m < 60 {
return if sr == 0 { format!("{}m", m) } else { format!("{}m {}s", m, sr) };
}
let (h, mr) = (m / 60, m % 60);
if h < 24 {
return if mr == 0 { format!("{}h", h) } else { format!("{}h {}m", h, mr) };
}
let (d, hr) = (h / 24, h % 24);
if hr == 0 { format!("{}d", d) } else { format!("{}d {}h", d, hr) }
}
/// Decide the config-key name for each model. Single model → bare
/// `AtomGit` (keeps the name tidy for the common case); 2+ models →
/// `AtomGit-{name with / replaced by -}`.
fn provider_names_for(model_names: &[String]) -> Vec<String> {
if model_names.len() == 1 {
vec![PROVIDER_PREFIX.to_string()]
} else {
model_names
.iter()
.map(|m| format!("{}-{}", PROVIDER_PREFIX, sanitize_model_for_name(m)))
.collect()
}
}
/// Turn `moonshotai/Kimi-K2-Instruct` → `moonshotai-Kimi-K2-Instruct`.
/// Only swaps `/`; other punctuation stays verbatim (model names in the
/// wild use `.` and digits freely, and TOML keys handle those fine).
fn sanitize_model_for_name(model: &str) -> String {
model.replace('/', "-")
}
/// Match `AtomGit` OR `AtomGit-<anything>` — the set of config keys
/// owned by the coding-plan flow. Used to wipe stale entries before
/// re-populating from the fresh model list.
fn is_codingplan_provider_name(name: &str) -> bool {
name == PROVIDER_PREFIX || name.starts_with(&format!("{}-", PROVIDER_PREFIX))
}
/// Build a ProviderConfig from a model-list entry. The server's
/// per-model fields take precedence; missing fields fall back to the
/// historical fallbacks ([`codingplan_llm_base_url`] / `PROVIDER_TYPE`
/// / `CONTEXT_WINDOW`) so older `models-v2` payloads without the new
/// columns continue to work without code changes.
///
/// `api_key` stays `None` regardless — `create_provider()` loads the
/// OAuth token at runtime via `auth.toml` so we never persist it into
/// the user's `config.toml`.
fn build_codingplan_provider(entry: &ModelEntry) -> ProviderConfig {
ProviderConfig {
provider_type: entry
.provider_type
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| PROVIDER_TYPE.to_string()),
api_key: None,
model: entry.display_model_name.clone(),
base_url: Some(
entry
.base_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(codingplan_llm_base_url),
),
system_prompt: None,
user_agent: None,
// `context_window: 0` from a misconfigured row would degrade
// every request to a zero-token window; treat that as
// "missing" and fall back rather than ship a broken provider.
context_window: entry
.context_window
.filter(|n| *n > 0)
.unwrap_or(CONTEXT_WINDOW),
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Build a `ModelEntry` for tests that only care about the
/// model name and want every other field to take its fallback
/// (`base_url` → [`codingplan_llm_base_url`], `provider_type` →
/// `PROVIDER_TYPE`, `context_window` → `CONTEXT_WINDOW`,
/// `plan_available: true`).
/// Lets the bulk of the test suite stay short while the
/// per-field-override behaviour gets its own dedicated tests
/// further down.
fn entry(display_model_name: &str) -> super::super::types::ModelEntry {
super::super::types::ModelEntry {
display_model_name: display_model_name.to_string(),
plan_available: true,
..Default::default()
}
}
fn blank_config() -> Config {
Config {
default_provider: String::new(),
default_workdir: None,
providers: HashMap::new(),
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
}
#[test]
fn single_model_uses_bare_prefix() {
let names = vec!["moonshotai/Kimi-K2-Instruct".into()];
let p = provider_names_for(&names);
assert_eq!(p, vec!["AtomGit".to_string()]);
}
#[test]
fn multiple_models_expand_to_prefix_suffixes() {
let names = vec![
"moonshotai/Kimi-K2-Instruct".into(),
"anthropic/claude-3.5-sonnet".into(),
"openai/gpt-5".into(),
];
let p = provider_names_for(&names);
assert_eq!(
p,
vec![
"AtomGit-moonshotai-Kimi-K2-Instruct".to_string(),
"AtomGit-anthropic-claude-3.5-sonnet".to_string(),
"AtomGit-openai-gpt-5".to_string(),
]
);
}
#[test]
fn sanitize_replaces_slash_only() {
// `/` becomes `-`; `.` and digits stay (valid in TOML keys).
assert_eq!(
sanitize_model_for_name("anthropic/claude-3.5-sonnet"),
"anthropic-claude-3.5-sonnet"
);
}
#[test]
fn is_codingplan_name_matches_prefix_and_exact() {
assert!(is_codingplan_provider_name("AtomGit"));
assert!(is_codingplan_provider_name("AtomGit-foo"));
assert!(is_codingplan_provider_name("AtomGit-moonshotai-Kimi-K2"));
assert!(!is_codingplan_provider_name("AtomGitPlus"));
assert!(!is_codingplan_provider_name("atomgit")); // case-sensitive
assert!(!is_codingplan_provider_name("claude"));
}
#[test]
fn step_models_wipes_stale_atomgit_entries() {
// Simulate a user who previously ran `/login` (old MiniMax entry)
// and a manual `/provider` session (custom Anthropic entry). After
// coding-plan setup, only fresh AtomGit* entries should remain;
// the manual Anthropic one stays.
let mut config = blank_config();
config.providers.insert(
"AtomGit".to_string(),
build_codingplan_provider(&entry("stale-MiniMax")),
);
config.providers.insert(
"AtomGit-legacy".to_string(),
build_codingplan_provider(&entry("another-stale")),
);
config.providers.insert(
"claude".to_string(),
build_codingplan_provider(&entry("anthropic/claude-3.5")),
);
// Manually drive the "install" side without network — mirror
// what step_models_and_register does after a successful API call.
let names = vec!["meta-llama/Llama-3-70B".to_string()];
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let provider_names = provider_names_for(&names);
for (pname, m) in provider_names.iter().zip(names.iter()) {
config
.providers
.insert(pname.clone(), build_codingplan_provider(&entry(m)));
}
config.default_provider = provider_names[0].clone();
assert_eq!(config.providers.len(), 2, "claude + one fresh AtomGit");
assert!(
config.providers.contains_key("claude"),
"unrelated entry kept"
);
assert!(
config.providers.contains_key("AtomGit"),
"fresh AtomGit added"
);
assert!(
!config.providers.contains_key("AtomGit-legacy"),
"stale removed"
);
let fresh = &config.providers["AtomGit"];
assert_eq!(fresh.model, "meta-llama/Llama-3-70B");
assert_eq!(
fresh.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(fresh.provider_type, PROVIDER_TYPE);
assert_eq!(config.default_provider, "AtomGit");
}
#[test]
fn codingplan_llm_base_url_defaults_to_new_signed_gateway() {
// Lock in the default. If `ATOMCODE_CODINGPLAN_LLM_BASE_URL` is
// set in the test environment (CI / staging override / dev box
// with a stray export), honour it — otherwise the default must
// be the modern `pre-llm-api-cce.atomgit.com` host. Anything else (most
// notably the legacy `api-ai.gitcode.com`) silently disables
// codingplan request signing because `is_atomgit_gateway` in
// `coding_plan::crypto` only whitelists the new host.
//
// OnceLock caches across test threads, so this test reflects
// whatever the env was at the FIRST call site in the process.
// That's deliberate — it ensures every test in this module
// agrees on the URL, mirroring production behaviour where the
// value is fixed for the lifetime of one `atomcode` run.
let actual = codingplan_llm_base_url();
let env_override = std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty());
if let Some(want) = env_override {
assert_eq!(actual, want, "env override must win when set");
} else {
assert_eq!(
actual, "https://pre-llm-api-cce.atomgit.com/v1",
"default must point at the new signed gateway (NOT legacy api-ai.gitcode.com); \
otherwise codingplan signing never engages"
);
}
}
#[test]
fn build_provider_uses_canonical_defaults() {
// All optional server fields missing → fall back to the
// historical constants. Pins the back-compat path for
// older `models-v2` builds that don't yet emit `base_url`,
// `type`, or `context_window`.
let p = build_codingplan_provider(&entry("foo/bar"));
assert_eq!(p.provider_type, "openai");
assert_eq!(
p.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(p.context_window, 64_000);
assert!(
p.api_key.is_none(),
"token loaded at runtime from auth.toml"
);
assert!(!p.ephemeral);
}
#[test]
fn build_provider_uses_server_overrides_when_present() {
// Per-model server fields take precedence over the
// hard-coded fallbacks. Mirrors the new wire shape:
// `base_url`, `type`, `context_window` all populated.
let e = super::super::types::ModelEntry {
id: 2052994857682014210,
is_infinity: 2,
is_atomcode_exclusive: 1,
display_model_name: "GLM-5.1".into(),
base_url: Some("https://custom.example.com/v1".into()),
provider_type: Some("claude".into()),
context_window: Some(128_000),
plan_available: true,
};
let p = build_codingplan_provider(&e);
assert_eq!(p.model, "GLM-5.1");
assert_eq!(p.provider_type, "claude");
assert_eq!(p.base_url.as_deref(), Some("https://custom.example.com/v1"));
assert_eq!(p.context_window, 128_000);
}
#[test]
fn build_provider_treats_empty_or_zero_overrides_as_missing() {
// Defensive: a malformed server row (empty string base_url /
// type, zero context window) shouldn't ship a provider that
// refuses every request. Fall back to constants instead.
let e = super::super::types::ModelEntry {
display_model_name: "weird".into(),
base_url: Some(String::new()),
provider_type: Some(String::new()),
context_window: Some(0),
plan_available: true,
..Default::default()
};
let p = build_codingplan_provider(&e);
assert_eq!(p.provider_type, "openai");
assert_eq!(
p.base_url.as_deref(),
Some(codingplan_llm_base_url().as_str())
);
assert_eq!(p.context_window, 64_000);
}
#[test]
fn model_entry_deserialises_new_wire_shape() {
// The exact JSON payload from the spec —
// every new field must parse without error.
let raw = r#"[{
"id": 2052994857682014210,
"is_infinity": 2,
"is_atomcode_exclusive": 1,
"display_model_name": "GLM-5.1",
"base_url": "https://api-ai.gitcode.com/v1",
"type": "openai",
"context_window": 64000,
"plan_available": true
}]"#;
let list: Vec<super::super::types::ModelEntry> =
serde_json::from_str(raw).expect("payload deserialises");
assert_eq!(list.len(), 1);
let m = &list[0];
assert_eq!(m.id, 2052994857682014210);
assert_eq!(m.is_infinity, 2);
assert_eq!(m.is_atomcode_exclusive, 1);
assert_eq!(m.display_model_name, "GLM-5.1");
assert_eq!(m.base_url.as_deref(), Some("https://api-ai.gitcode.com/v1"));
assert_eq!(m.provider_type.as_deref(), Some("openai"));
assert_eq!(m.context_window, Some(64_000));
assert!(m.plan_available);
}
#[test]
fn model_entry_deserialises_legacy_wire_shape() {
// Older server build with only the v2-minimum fields. New
// fields default to `None` / `0` so older payloads keep
// working — the orchestrator falls back to the constants.
let raw = r#"[{
"id": 1,
"is_atomcode_exclusive": 0,
"display_model_name": "legacy/model",
"plan_available": true
}]"#;
let list: Vec<super::super::types::ModelEntry> =
serde_json::from_str(raw).expect("legacy payload deserialises");
let m = &list[0];
assert_eq!(m.display_model_name, "legacy/model");
assert!(m.base_url.is_none());
assert!(m.provider_type.is_none());
assert!(m.context_window.is_none());
assert_eq!(m.is_infinity, 0);
}
/// Render exercise: every step Ok. Verifies the three-line output
/// structure the user sees on a fresh happy-path run.
#[test]
fn render_happy_path_has_all_checkmarks() {
let report = SetupReport {
login: StepResult::Ok(LoginInfo {
username: "theo".into(),
display_name: Some("Theo".into()),
email: Some("theo@example.com".into()),
}),
claim: StepResult::Ok(ClaimInfo {
message: "领取成功".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["moonshotai/Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
codingplan_free: Some(crate::coding_plan::types::PlanInfo {
plan_name: "CodingPlan Free".into(),
status: 1,
claimed_at: "2026-04-22".into(),
expires_at: "2026-05-22".into(),
remaining_days: 29,
total_days: 30,
apply_id: 1,
}),
current_usage: Some(crate::coding_plan::types::UsageInfo {
placeholder: false,
window_token_limit: 50000,
window_tokens_used: 0,
usage_percent: 0.0,
window_hours: 1,
reset_at: "2026-04-23T12:13:14".into(),
reset_at_display: "12:13".into(),
seconds_until_reset: 693,
reset_label: String::new(),
usage_status_desc: String::new(),
}),
audit_status: 1,
expires_at: Some("2026-05-22".into()),
window_quota_exhausted: false,
window_quota_hint: None,
rate_limit_windows: vec![],
}),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✓ Logged in as Theo"));
assert!(out.contains("theo@example.com"));
assert!(out.contains("CodingPlan claimed"));
assert!(out.contains("Kimi-K2-Instruct"));
assert!(out.contains("AtomGit"));
assert!(out.contains("(default)"));
assert!(out.contains("CodingPlan Free"));
assert!(out.contains("12:13"));
assert!(report.should_persist_config());
}
/// Render exercise: claim returned duplicate=true. Must render as
/// a skipped checkmark, NOT a failure — user already had the plan.
#[test]
fn render_claim_duplicate_renders_as_success() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Skipped("already claimed / in review".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("request timeout".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("✓ already logged in"));
assert!(out.contains("already claimed"));
assert!(!out.contains("× CodingPlan claim"), "duplicate ≠ failure");
// Status failed but it's warn-only: ⚠ prefix, NOT ✗.
assert!(out.contains("⚠ Status fetch failed"));
assert!(!out.contains("× Status"));
// Login skipped + models ok ⇒ config should still be persisted.
assert!(report.should_persist_config());
}
/// Regression: when a fresh claim hasn't activated yet the backend
/// returns `claimed_at: null, expires_at: null, total_days: 0,
/// remaining_days: 0`. Pre-fix the render line came out as
/// `Plan: CodingPlan Free · expires (0d / 0d remaining)` — empty
/// gap in the middle + bogus zeros, looked like a parser bug. Now
/// the empty-expiry case shows a meaningful "pending activation"
/// state instead.
#[test]
fn render_status_pending_activation_omits_zero_expiry() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
codingplan_free: Some(crate::coding_plan::types::PlanInfo {
plan_name: "CodingPlan Free".into(),
status: 0,
claimed_at: String::new(),
expires_at: String::new(),
remaining_days: 0,
total_days: 0,
apply_id: 0,
}),
current_usage: None,
audit_status: 0,
expires_at: None,
window_quota_exhausted: false,
window_quota_hint: None,
rate_limit_windows: vec![],
}),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Plan: CodingPlan Free"), "plan name still shown: {}", out);
assert!(
out.contains("pending activation"),
"must surface pending state to user: {}",
out
);
assert!(
!out.contains("(0d / 0d"),
"bogus zero countdown must not render: {}",
out
);
assert!(
!out.contains("expires ("),
"empty expires-date with double space must not render: {}",
out
);
}
/// Render exercise: login failed. Downstream steps are pre-marked
/// with the cascade sentinel; format() suppresses them so only the
/// login-failure line appears. Config must NOT be persisted.
#[test]
fn render_login_failed_blocks_persist_and_suppresses_cascade() {
let report = SetupReport {
login: StepResult::Err("browser handshake timed out".into()),
claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("× Login failed"));
// Cascade rows must NOT appear.
assert!(!out.contains("CodingPlan claim"), "no cascade claim row on login fail");
assert!(!out.contains("Models step"), "no cascade models row on login fail");
assert!(!out.contains("Status fetch"), "no cascade status row on login fail");
// Login Err ⇒ should_persist_config = false (login.is_ok_or_skipped() is false).
assert!(
!report.should_persist_config(),
"don't write config on login failure"
);
}
/// Regression: claim returned Err (e.g. AtomGit `claim-v2` 500
/// with the Spring `UnexpectedRollbackException` payload). `run()`
/// short-circuits and stamps the cascade sentinel into `models` /
/// `status`. Before this fix, `should_persist_config` only
/// checked `login` and `models` — both `is_ok_or_skipped()` =
/// `true` here — so the gate flipped open and `save_and_reload`
/// rewrote `config.toml`. Surfaced to the user as "claim failed
/// but models still got written to my config". Now the predicate
/// also requires `claim.is_ok_or_skipped()` so any real claim
/// failure blocks the persist.
#[test]
fn claim_err_blocks_persist() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(
"claim Pro request: claim-v2 returned 500 Internal Server Error \
— Transaction rolled back because it has been marked as rollback-only"
.into(),
),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
assert!(
!report.should_persist_config(),
"claim Err must block save_and_reload — config rewrite was overwriting \
manual edits between TUI startup and /codingplan",
);
// Sanity-check: the duplicate-claim Skipped path (server says
// "already claimed") must STILL persist so two-runs-in-a-row
// /codingplan keeps working as a model-list sync.
let dup = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Skipped("already claimed / using Max".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("status fetch timeout".into()),
auth_expired: false,
};
assert!(
dup.should_persist_config(),
"duplicate-claim Skipped must still allow persist (it's the model-sync path)",
);
}
/// `auth_expired = true` MUST NOT flip `should_persist_config()`
/// open on its own — the gate already requires every critical step
/// to be `is_ok_or_skipped`, and that's where the actual safety
/// lives. `auth_expired` is a side-channel for the shell to decide
/// "retry with fresh OAuth"; it's orthogonal to "is this report
/// good enough to write to disk". Regression guard: a future
/// refactor that ANDs `auth_expired` into the predicate would
/// double-gate (claim Err + auth_expired both block) but a future
/// refactor that ORs it the wrong way would open the persist gate
/// on an auth-expired-but-otherwise-skipped report. Lock the
/// orthogonality in.
#[test]
fn auth_expired_alone_does_not_change_persist_gate() {
// All-Skipped report (login skipped, no claim attempted, etc.)
// with auth_expired=true. Persist gate is driven by the step
// outcomes — Skipped counts as "ok or skipped" — so this should
// still allow persist.
let allow = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Skipped("already claimed".into()),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Skipped("ok".into()),
auth_expired: true,
};
assert!(
allow.should_persist_config(),
"auth_expired must not gate persist when every critical step \
is ok/skipped — it's a side-channel for retry, not safety",
);
// Claim Err report. Persist gate already false, auth_expired
// doesn't matter.
let block = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err("auth failed".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: true,
};
assert!(
!block.should_persist_config(),
"claim Err already blocks persist — auth_expired doesn't \
relax it",
);
}
/// Per-tier cascade rendering: Max refused (额度已满) → Pro
/// refused (额度已满) → Lite claimed. Only the winning tier
/// surfaces — intermediate refusals like "Max 套餐尚未开放" are
/// noise once a higher tier succeeds, and users will pay for
/// upgrades on the web rather than via the CLI cascade.
#[test]
fn render_per_tier_cascade_shows_only_winning_tier() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as Code_dh".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Lite,
}),
claim_attempts: vec![
TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Refused {
message: "额度已满".into(),
},
},
TierAttempt {
tier: PlanType::Pro,
outcome: TierOutcome::Refused {
message: "额度已满".into(),
},
},
TierAttempt {
tier: PlanType::Lite,
outcome: TierOutcome::Claimed {
message: "领取成功".into(),
},
},
],
models: StepResult::Skipped("models step not exercised here".into()),
status: StepResult::Skipped("status not exercised here".into()),
auth_expired: false,
};
let out = report.render();
// Lite must surface as 生效 / active (the winner).
assert!(
out.contains("CodingPlan Lite 生效")
|| out.contains("CodingPlan Lite active"),
"Lite success row missing: {}",
out
);
// Max + Pro refusal rows must NOT appear — the cascade
// intermediates are suppressed by design ("Max 套餐尚未开放"
// was spamming every successful run before this change).
assert!(
!out.contains("CodingPlan Max"),
"Max refusal row must be suppressed: {}",
out
);
assert!(
!out.contains("CodingPlan Pro"),
"Pro refusal row must be suppressed: {}",
out
);
// 「领取」字样应彻底消失 — neither successes nor failures
// should still carry the old claim wording.
assert!(
!out.contains("领取"),
"no '领取' wording should remain in rendered output: {}",
out
);
// The legacy single-line summary must NOT appear when
// claim_attempts is populated — would be a duplicate row.
assert!(
!out.contains("CodingPlan claimed"),
"legacy claim-summary row must be suppressed when per-tier rows present: {}",
out
);
}
/// Per-tier cascade where every tier refused — winning tier is
/// `None`, overall claim is `Err`. Per-tier failure rows are
/// suppressed; a single consolidated failure line surfaces so the
/// user still gets a signal that no plan was activated.
#[test]
fn render_per_tier_cascade_all_refused() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
// Bare server message — no English wrapper. Mirrors the
// new try_claim_with_cascade() output shape.
claim: StepResult::Err("Lite: 暂无开放".into()),
claim_attempts: vec![
TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
TierAttempt {
tier: PlanType::Pro,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
TierAttempt {
tier: PlanType::Lite,
outcome: TierOutcome::Refused {
message: "暂无开放".into(),
},
},
],
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
// No per-tier failure rows — `Max/Pro/Lite` strings must not
// appear anywhere in the output.
for tier in &["Max", "Pro", "Lite"] {
let needle = format!("CodingPlan {}", tier);
assert!(
!out.contains(&needle),
"{} tier row must be suppressed in all-refused case: {}",
tier,
out
);
}
// 「领取」字样应彻底消失.
assert!(
!out.contains("领取"),
"no '领取' wording should remain in rendered output: {}",
out
);
// BUT we must still emit a single consolidated failure line so
// the user knows the step didn't succeed — driven by the
// overall `claim` Err falling through to CpClaimFailed.
assert!(
out.contains("CodingPlan 套餐配置失败") || out.contains("CodingPlan tier setup failed"),
"consolidated failure row must appear when no tier succeeds: {}",
out
);
// Models row also suppressed (cascade sentinel).
assert!(
!out.contains("Models step"),
"cascade-from-claim-fail must hide models row: {}",
out
);
}
/// Per-tier cascade where Max errored (5xx). Per-tier failure
/// rows are suppressed, but the overall `claim` Err falls through
/// to a single consolidated failure line — and that line must
/// truncate a long error so a 500-char stack trace doesn't blow
/// up the row.
#[test]
fn render_all_errored_consolidated_row_truncates_long_message() {
let long_err = "x".repeat(500);
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(format!("claim Max request: {}", long_err)),
claim_attempts: vec![TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Errored {
error: long_err.clone(),
},
}],
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
// No per-tier "Max" row.
assert!(
!out.contains("CodingPlan Max"),
"per-tier Max row must be suppressed: {}",
out
);
// Consolidated failure row is present (driven by claim Err).
assert!(
out.contains("CodingPlan 套餐配置失败") || out.contains("CodingPlan tier setup failed"),
"consolidated failure row must appear: {}",
out
);
// The full 500-char error must NOT appear verbatim — truncated.
assert!(
!out.contains(&long_err),
"long error must be truncated, not pasted whole: {}",
out
);
}
/// All-fail with no server detail anywhere — overall Err is empty
/// AND every TierAttempt has empty message/error. Render must
/// emit the bare-prefix variant (no dangling `— `).
#[test]
fn render_all_failed_with_no_detail_uses_bare_prefix() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Err(String::new()),
claim_attempts: vec![
TierAttempt {
tier: PlanType::Max,
outcome: TierOutcome::Refused {
message: String::new(),
},
},
TierAttempt {
tier: PlanType::Pro,
outcome: TierOutcome::Refused {
message: String::new(),
},
},
TierAttempt {
tier: PlanType::Lite,
outcome: TierOutcome::Refused {
message: String::new(),
},
},
],
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
// Prefix renders.
assert!(
out.contains("CodingPlan 套餐配置失败") || out.contains("CodingPlan tier setup failed"),
"bare failure prefix must appear: {}",
out
);
// No dangling em-dash — body is empty so the suffix is skipped.
assert!(
!out.contains("套餐配置失败 — ") && !out.contains("tier setup failed — "),
"must not render `— ` with empty body: {:?}",
out
);
}
/// Render exercise: multi-model report. Verifies each provider
/// name gets its own bullet + `(default)` marks only the first.
#[test]
fn render_multi_model_lists_all_providers_with_default_mark() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Ok(ClaimInfo {
message: String::new(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"moonshotai/Kimi-K2-Instruct".into(),
"anthropic/claude-3.5-sonnet".into(),
"openai/gpt-5".into(),
],
provider_names: vec![
"AtomGit-moonshotai-Kimi-K2-Instruct".into(),
"AtomGit-anthropic-claude-3.5-sonnet".into(),
"AtomGit-openai-gpt-5".into(),
],
default_provider: "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err("status endpoint 500".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Added 3 providers"));
assert!(out.contains(
"AtomGit-moonshotai-Kimi-K2-Instruct → moonshotai/Kimi-K2-Instruct (default)"
));
assert!(
out.contains("AtomGit-anthropic-claude-3.5-sonnet → anthropic/claude-3.5-sonnet\n")
);
assert!(
!out.contains("anthropic/claude-3.5-sonnet (default)"),
"only first is default"
);
}
/// Render exercise: claim failed. The cascade markers on models +
/// status must render as nothing — the claim-failed line is the
/// explanation, repeating it twice more is noise.
#[test]
fn render_claim_failed_suppresses_cascade_rows() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Err("今日codingplan申请额度已满,请明天再试".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("× CodingPlan tier setup failed"));
assert!(out.contains("今日codingplan申请额度已满"));
// The cascade rows must NOT appear.
assert!(!out.contains("Models step skipped"), "no cascade row for models");
assert!(!out.contains("Status fetch skipped"), "no cascade row for status");
assert!(!out.contains("Added "), "must not say 'Added N providers' on claim fail");
// The huge JSON body that used to leak through here must NOT appear.
assert!(!out.contains("invalid type: null"));
assert!(!out.contains("plan_name"));
}
/// Non-cascade Skipped reasons still render — only the sentinel
/// (`__cascade_upstream_fail__`) is suppressed.
#[test]
fn render_skipped_with_non_cascade_reason_still_shows() {
let report = SetupReport {
login: StepResult::Skipped("already logged in as theo".into()),
claim: StepResult::Skipped("already claimed".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped("models cached locally".into()),
status: StepResult::Skipped("server returned 503; using cached".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Models step skipped — models cached locally"));
assert!(out.contains("Status fetch skipped — server returned 503"));
}
/// Render exercise: status fetch failed with a multi-KB body chain.
/// Output must be truncated to keep the report readable.
#[test]
fn render_status_error_truncates_long_message() {
let huge = format!(
"status: parse status response (body: {}): invalid type",
"x".repeat(1000),
);
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "ok".into(),
duplicate: false,
plan_type: PlanType::Max,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["a/b".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Err(huge),
auth_expired: false,
};
let out = report.render();
// Find the status line and check its length is bounded.
let line = out.lines().find(|l| l.contains("Status fetch failed")).unwrap();
// 150 chars + ellipsis + prefix + leading spaces ⇒ comfortably under 250.
assert!(line.chars().count() < 250, "line still ~{} chars long", line.chars().count());
assert!(line.contains('…'), "truncation marker present");
}
#[test]
fn format_duration_secs_human_readable() {
assert_eq!(format_duration_secs(0), "0s");
assert_eq!(format_duration_secs(45), "45s");
assert_eq!(format_duration_secs(60), "1m");
assert_eq!(format_duration_secs(90), "1m 30s");
assert_eq!(format_duration_secs(3600), "1h");
assert_eq!(format_duration_secs(3660), "1h 1m");
assert_eq!(format_duration_secs(86400), "1d");
assert_eq!(format_duration_secs(90060), "1d 1h");
assert_eq!(format_duration_secs(-1), "—");
}
#[test]
fn truncate_inline_passes_short_strings_through() {
assert_eq!(truncate_inline("short", 10), "short");
assert_eq!(truncate_inline("exactly_ten", 11), "exactly_ten");
}
#[test]
fn truncate_inline_appends_ellipsis_when_long() {
let r = truncate_inline("abcdefghijklmnop", 5);
assert_eq!(r, "abcde…");
}
#[test]
fn truncate_inline_handles_unicode_safely() {
// 5 CJK chars = 5 chars (regardless of byte count). No char-boundary panic.
let r = truncate_inline("一二三四五六七八", 5);
assert_eq!(r, "一二三四五…");
}
// ── Vision-preprocessor auto-config tests ────────────────────────────
fn vl_model_entry(model: &str) -> super::super::types::ModelEntry {
super::super::types::ModelEntry {
id: 1,
display_model_name: model.to_string(),
// Tests in this section drive `run_register` directly with
// a curated `Vec<ModelEntry>` — they're testing the
// post-availability-filter logic, so every entry counts as
// "available". The split-by-`plan_available` happens
// upstream in the real `step_models_and_register`.
plan_available: true,
// The new wire-shape optional fields default to None/0 —
// these tests only care about the model name and the
// availability flag, so let them fall back to the
// constants via `Default`.
..Default::default()
}
}
/// Helper that mirrors `step_models_and_register`'s wipe-and-insert
/// + auto-detect body, sans network call. Tests the precedence logic
/// in isolation.
fn run_register(
config: &mut Config,
models: Vec<super::super::types::ModelEntry>,
) -> ModelsInfo {
let stale: Vec<String> = config
.providers
.keys()
.filter(|k| is_codingplan_provider_name(k))
.cloned()
.collect();
for k in stale {
config.providers.remove(&k);
}
let names: Vec<String> = models.iter().map(|m| m.display_model_name.clone()).collect();
let provider_names = provider_names_for(&names);
let default_provider = provider_names
.first()
.cloned()
.unwrap_or_else(|| PROVIDER_PREFIX.to_string());
for (pname, m) in provider_names.iter().zip(models.iter()) {
config
.providers
.insert(pname.clone(), build_codingplan_provider(m));
}
config.default_provider = default_provider.clone();
let vl_idx = names
.iter()
.position(|n| crate::provider::model_name_suggests_vision(n));
let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
let vision_preprocessor = {
let current = config.vision_preprocessor_provider.clone();
let user_supplied_non_atomgit = current
.as_deref()
.map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
.unwrap_or(false);
if user_supplied_non_atomgit {
VisionPreprocessorOutcome::UserSupplied(current.unwrap())
} else {
match new_vl_key {
Some(k) => {
config.vision_preprocessor_provider = Some(k.clone());
VisionPreprocessorOutcome::AutoSet(k)
}
None => {
if current.is_some() {
config.vision_preprocessor_provider = None;
VisionPreprocessorOutcome::Cleared
} else {
VisionPreprocessorOutcome::UnchangedNone
}
}
}
}
};
ModelsInfo {
display_names: names,
provider_names,
default_provider,
vision_preprocessor,
// Test helper doesn't exercise the locked-model rendering
// path; mirror the input slice into all_models so the
// shape stays consistent if any future assertion peeks.
all_models: models,
}
}
#[test]
fn vision_preprocessor_auto_set_when_none_and_list_has_vl() {
let mut config = blank_config();
let models = vec![
vl_model_entry("moonshotai/Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
vl_model_entry("deepseek/deepseek-v4-flash"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn vision_preprocessor_unchanged_none_when_list_has_no_vl() {
let mut config = blank_config();
let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
let info = run_register(&mut config, models);
assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::UnchangedNone);
assert_eq!(config.vision_preprocessor_provider, None);
}
#[test]
fn vision_preprocessor_overwrites_stale_atomgit_value() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn vision_preprocessor_cleared_when_stale_atomgit_and_list_has_no_vl() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
let info = run_register(&mut config, models);
assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::Cleared);
assert_eq!(config.vision_preprocessor_provider, None);
}
#[test]
fn vision_preprocessor_preserves_user_set_non_atomgit() {
let mut config = blank_config();
config.vision_preprocessor_provider = Some("Qwen3-VL-32B-Instruct".into());
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
];
let info = run_register(&mut config, models);
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::UserSupplied("Qwen3-VL-32B-Instruct".into())
);
assert_eq!(
config.vision_preprocessor_provider.as_deref(),
Some("Qwen3-VL-32B-Instruct")
);
}
#[test]
fn vision_preprocessor_recognises_pure_ocr_model_name() {
let mut config = blank_config();
let models = vec![
vl_model_entry("Kimi-K2-Instruct"),
vl_model_entry("PaddleOCR-2.0"),
];
let info = run_register(&mut config, models);
let expected = "AtomGit-PaddleOCR-2.0".to_string();
assert_eq!(
info.vision_preprocessor,
VisionPreprocessorOutcome::AutoSet(expected.clone())
);
assert_eq!(config.vision_preprocessor_provider, Some(expected));
}
#[test]
fn render_includes_vision_preprocessor_auto_set_line() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"Kimi-K2-Instruct".into(),
"Qwen/Qwen3-VL-32B-Instruct".into(),
],
provider_names: vec![
"AtomGit-Kimi-K2-Instruct".into(),
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::AutoSet(
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
),
all_models: vec![],
}),
status: StepResult::Skipped("status check skipped for this test".into()),
auth_expired: false,
};
let out = report.render();
assert!(
out.contains("Vision preprocessor → AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
"render must include the auto-detected line: {out}",
);
assert!(out.contains("(auto-detected)"));
}
#[test]
fn render_includes_vision_preprocessor_cleared_line_when_stale_dropped() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::Cleared,
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Vision preprocessor cleared"));
}
#[test]
fn render_includes_vision_preprocessor_user_supplied_line() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec![
"Kimi-K2-Instruct".into(),
"Qwen/Qwen3-VL-32B-Instruct".into(),
],
provider_names: vec![
"AtomGit-Kimi-K2-Instruct".into(),
"AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UserSupplied(
"Qwen3-VL-32B-Instruct".into(),
),
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(out.contains("Vision preprocessor → Qwen3-VL-32B-Instruct"));
assert!(out.contains("(user setting kept)"));
}
#[test]
fn render_omits_vision_preprocessor_line_when_unchanged_none() {
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["Kimi-K2-Instruct".into()],
provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
default_provider: "AtomGit-Kimi-K2-Instruct".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
assert!(!out.contains("Vision preprocessor"));
}
// ── rate_limit_windows rendering tests ─────────────────────────────
fn blank_status_response() -> crate::coding_plan::types::StatusResponse {
crate::coding_plan::types::StatusResponse {
codingplan_free: None,
current_usage: None,
audit_status: 0,
expires_at: None,
window_quota_exhausted: false,
window_quota_hint: None,
rate_limit_windows: vec![],
}
}
fn status_only_report(
s: crate::coding_plan::types::StatusResponse,
) -> SetupReport {
SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Skipped("already claimed".into()),
claim_attempts: Vec::new(),
models: StepResult::Skipped("models not exercised".into()),
status: StepResult::Ok(s),
auth_expired: false,
}
}
#[test]
fn render_uses_rate_limit_windows_when_present() {
// rate_limit_windows populated → prefers new field over current_usage.
let s = crate::coding_plan::types::StatusResponse {
rate_limit_windows: vec![
RateLimitWindow {
rule_index: 0,
show_enable: 1,
window_size_seconds: 18000,
window_hours: 5,
call_limit: 1000,
calls_used: 20,
usage_percent: 2.0,
quota_exhausted: false,
reset_at: "2026-05-26T18:09:30".into(),
reset_at_display: "18:09".into(),
seconds_until_reset: 16080,
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 2%".into(),
},
],
..blank_status_response()
};
let out = status_only_report(s).render();
assert!(out.contains("当前时间窗口用量约 2%"), "usage_status_desc missing: {}", out);
assert!(out.contains("18:09"), "reset_at_display missing: {}", out);
}
#[test]
fn render_long_window_shows_monthly_exhausted() {
// window_size_seconds > 5h * 3600 = 18000 → monthly-exhausted message.
// Output: `用量:本月用量已耗尽,等 {duration} 后再使用` —
// the reset clock-time is intentionally dropped (it's typically a
// far-off "06-20 23:09" the user can't act on; the duration anchor
// `25d` reads more usefully).
let s = crate::coding_plan::types::StatusResponse {
rate_limit_windows: vec![
RateLimitWindow {
rule_index: 1,
show_enable: 1,
window_size_seconds: 2592000, // 30 days
window_hours: 720,
call_limit: 16000,
calls_used: 16000,
usage_percent: 100.0,
quota_exhausted: true,
reset_at: "2026-06-20T23:09:30".into(),
reset_at_display: "23:09".into(),
seconds_until_reset: 2194080, // ~25.4d
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 100%".into(),
},
],
..blank_status_response()
};
let out = status_only_report(s).render();
assert!(
out.contains("本月用量已耗尽") || out.contains("monthly quota exhausted"),
"long-window message missing: {}",
out
);
// Duration anchor present, clock time absent.
assert!(
out.contains("25d") || out.contains("in 25d"),
"duration anchor missing: {}",
out
);
assert!(!out.contains("23:09"), "clock-time should be dropped: {}", out);
}
/// User-reported scenario: monthly quota at 100% AND server also
/// reports a fresh 5h rolling window at 0%. Showing both produces
/// the misleading `用量 0% 重置于 2h / ⚠ 本月用量已耗尽 12d` pair
/// (the 0%-line gave false hope — even with 5h capacity the user
/// can't issue calls because the monthly cap gates everything).
/// Expected: collapse to the single monthly-exhausted line.
#[test]
fn render_monthly_exhausted_suppresses_short_window_line() {
let s = crate::coding_plan::types::StatusResponse {
rate_limit_windows: vec![
// 5h rolling, fresh — show_enable=1 from server.
RateLimitWindow {
rule_index: 0,
show_enable: 1,
window_size_seconds: 18000,
window_hours: 5,
call_limit: 500,
calls_used: 0,
usage_percent: 0.0,
quota_exhausted: false,
reset_at: "2026-05-28T22:49:00".into(),
reset_at_display: "22:49".into(),
seconds_until_reset: 7200, // 2h
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 0%".into(),
},
// 30d monthly, exhausted. The real server HIDES this window
// (show_enable=0) while still flagging quota_exhausted=true —
// exhaustion must be detected via the flag, not show_enable.
RateLimitWindow {
rule_index: 1,
show_enable: 0,
window_size_seconds: 2592000,
window_hours: 720,
call_limit: 16000,
calls_used: 16000,
usage_percent: 100.0,
quota_exhausted: true,
reset_at: "2026-06-09T22:49:00".into(),
reset_at_display: "06-09 22:49".into(),
seconds_until_reset: 1036800, // 12d
reset_label: String::new(),
usage_status_desc: "本月用量约 100%".into(),
},
],
..blank_status_response()
};
let out = status_only_report(s).render();
// Monthly exhausted line present.
assert!(
out.contains("本月用量已耗尽") || out.contains("monthly quota exhausted"),
"monthly-exhausted line missing: {}",
out
);
// Short-window 0% line SUPPRESSED — the whole point of this fix.
assert!(
!out.contains("用量约 0%") && !out.contains("Usage: 当前时间窗口用量约 0%"),
"short-window 0% line must be suppressed when monthly exhausted: {}",
out
);
// Short window's `2h` reset duration should not leak either —
// would tell the user a stale "你还有 2 小时" anchor.
assert!(
!out.contains("(2h 后)") && !out.contains("(in 2h)"),
"short-window 2h duration must not leak: {}",
out
);
// Monthly window's 12d duration is what shows.
assert!(
out.contains("12d"),
"monthly 12d duration missing: {}",
out
);
}
#[test]
fn blocking_exhausted_window_detects_hidden_monthly() {
// Exact shape the server returns once the 30d monthly quota is spent:
// the 5h rolling window is visible at 0%, the exhausted monthly is
// HIDDEN (show_enable=0) but flagged quota_exhausted=true.
let windows = vec![
RateLimitWindow {
rule_index: 0,
show_enable: 1,
window_size_seconds: 18000,
window_hours: 5,
call_limit: 500,
calls_used: 0,
usage_percent: 0.0,
quota_exhausted: false,
reset_at: "2026-06-01T17:58:32".into(),
reset_at_display: "17:58".into(),
seconds_until_reset: 14716,
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 0%".into(),
},
RateLimitWindow {
rule_index: 1,
show_enable: 0,
window_size_seconds: 2592000,
window_hours: 720,
call_limit: 8000,
calls_used: 8000,
usage_percent: 100.0,
quota_exhausted: true,
reset_at: "2026-06-26T07:58:32".into(),
reset_at_display: "07:58".into(),
seconds_until_reset: 2138716,
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 100%".into(),
},
];
// Despite show_enable=0, the exhausted monthly window is detected.
let blocking = super::blocking_exhausted_window(&windows);
assert!(blocking.is_some(), "hidden exhausted monthly must be detected");
assert_eq!(blocking.unwrap().rule_index, 1);
// No exhaustion → None (so the rolling usage line renders normally).
let mut fresh = windows.clone();
fresh[1].quota_exhausted = false;
assert!(super::blocking_exhausted_window(&fresh).is_none());
}
#[test]
fn render_falls_back_to_current_usage_when_windows_empty() {
// rate_limit_windows empty → backward-compat path using current_usage.
let s = crate::coding_plan::types::StatusResponse {
current_usage: Some(crate::coding_plan::types::UsageInfo {
placeholder: false,
window_token_limit: 0,
window_tokens_used: 0,
usage_percent: 5.0,
window_hours: 5,
reset_at: "2026-05-26T18:09:30".into(),
reset_at_display: "18:09".into(),
seconds_until_reset: 16080,
reset_label: String::new(),
usage_status_desc: "当前时间窗口用量约 5%".into(),
}),
rate_limit_windows: vec![], // empty → backward-compat path
..blank_status_response()
};
let out = status_only_report(s).render();
assert!(out.contains("当前时间窗口用量约 5%"), "fallback usage missing: {}", out);
assert!(out.contains("18:09"), "reset_at_display missing: {}", out);
}
#[test]
fn render_rate_limit_window_hidden_when_show_enable_zero() {
// show_enable=0 windows must be skipped; only show_enable=1 rendered.
let s = crate::coding_plan::types::StatusResponse {
rate_limit_windows: vec![
RateLimitWindow {
rule_index: 0,
show_enable: 1,
window_size_seconds: 18000,
window_hours: 5,
call_limit: 1000,
calls_used: 50,
usage_percent: 5.0,
quota_exhausted: false,
reset_at: "2026-05-26T18:09:30".into(),
reset_at_display: "18:09".into(),
seconds_until_reset: 16080,
reset_label: String::new(),
usage_status_desc: "visible window".into(),
},
RateLimitWindow {
rule_index: 1,
show_enable: 0, // must NOT render
window_size_seconds: 2592000,
window_hours: 720,
call_limit: 16000,
calls_used: 5000,
usage_percent: 31.0,
quota_exhausted: false,
reset_at: "2026-06-20T23:09:30".into(),
reset_at_display: "23:09".into(),
seconds_until_reset: 2194080,
reset_label: String::new(),
usage_status_desc: "hidden window".into(),
},
],
..blank_status_response()
};
let out = status_only_report(s).render();
assert!(out.contains("visible window"), "show_enable=1 window missing: {}", out);
assert!(!out.contains("hidden window"), "show_enable=0 window must not render: {}", out);
assert!(!out.contains("23:09"), "hidden window reset_at must not appear: {}", out);
}
/// Locked models (plan_available=false on a higher tier) must
/// surface in the rendered report with a distinctive `×` prefix
/// + the explicit "(requires Pro plan or higher)" suffix, the whole row
/// wrapped in SGR 31 (terminal-theme red), and appended to the
/// same `Added N provider(s)` bullet list as the available models
/// so users see the full slate at a glance. Pins the v2 spec's
/// "若不可用的模型也展示出来" requirement.
///
/// Three layered signals — colour, prefix glyph, suffix text —
/// because each can fail independently:
/// * SGR 31 only fires when the renderer's sanitizer keeps SGR
/// (plain) — retained's strict strip pathway drops the colour
/// but the glyph + text still carry the meaning.
/// * The `×` glyph (U+00D7 MULTIPLICATION SIGN) is Latin-1 and
/// present in every terminal font (unlike U+2717 ✗ which is
/// missing from macOS Terminal.app's default font).
/// * The "(requires Pro plan or higher)" suffix is plain ASCII / CJK
/// and survives even font-fallback-tofu rendering.
///
/// Earlier attempts at strikethrough (SGR 9 then U+0336
/// combining mark) were both dropped — SGR 9 was eaten by the
/// universal CSI sanitizer, and U+0336 was silently skipped by
/// some fonts in the wild — so this test also pins that those
/// markers do NOT regress back into the template.
#[test]
fn render_shows_locked_models_with_prefix_marker() {
let avail = super::super::types::ModelEntry {
id: 1,
display_model_name: "lite/foo".into(),
plan_available: true,
..Default::default()
};
let locked = super::super::types::ModelEntry {
id: 2,
display_model_name: "max/super-secret".into(),
plan_available: false,
..Default::default()
};
let report = SetupReport {
login: StepResult::Skipped("already logged in".into()),
claim: StepResult::Ok(ClaimInfo {
message: "claimed".into(),
duplicate: false,
plan_type: PlanType::Lite,
}),
claim_attempts: Vec::new(),
models: StepResult::Ok(ModelsInfo {
display_names: vec!["lite/foo".into()],
provider_names: vec!["AtomGit".into()],
default_provider: "AtomGit".into(),
vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
all_models: vec![avail, locked],
}),
status: StepResult::Skipped("test skip".into()),
auth_expired: false,
};
let out = report.render();
// Plan tier appears next to claim line.
assert!(out.contains("(CodingPlan Lite)"), "claim row must show tier:\n{out}");
// Available model: standard provider line.
assert!(out.contains("AtomGit") && out.contains("lite/foo"));
// Locked model: `×` prefix immediately before the name, plus
// the explicit `(requires Pro plan or higher)` suffix, all wrapped
// in SGR 31 (red fg) → SGR 39 (default fg) so the terminal
// renders the whole row in the theme's red.
assert!(
out.contains("\x1b[31m× max/super-secret"),
"locked model must open with SGR 31 + × prefix:\n{out}"
);
assert!(out.contains("(requires Pro plan or higher)\x1b[39m"));
// Strikethrough is intentionally NOT used (SGR 9 was eaten by
// the renderer's CSI sanitizer; U+0336 was font-dependent and
// silently dropped on some setups). Lock those decisions in.
assert!(
!out.contains("\x1b[9m"),
"locked-model line must not emit SGR 9 strikethrough:\n{out}"
);
assert!(
!out.contains('\u{0336}'),
"locked-model line must not emit U+0336 combining strikethrough:\n{out}"
);
// Locked model appears INSIDE the providers bullet list — its
// line must come after the "Added N provider(s):" header and
// before the next top-level section (Vision preprocessor /
// CodingPlan status). The strikethrough + suffix already mark
// it as unavailable; no separate "locked model" header.
assert!(
!out.contains("locked model"),
"no separate locked-model section expected:\n{out}"
);
let added_idx = out.find("Added 1 provider").expect("Added header");
let locked_idx = out.find("max/super-secret").expect("locked model line");
let avail_idx = out.find("lite/foo").expect("available model line");
assert!(
locked_idx > added_idx,
"locked model must render after the Added header:\n{out}"
);
assert!(
locked_idx < avail_idx,
"locked model must render BEFORE available providers (top-of-list upgrade prompt):\n{out}"
);
}
}