1use anyhow::Result;
33use std::sync::Arc;
34
35use super::client::{is_auth_expired, Client};
36use super::types::{ModelEntry, PlanType, StatusResponse};
37use crate::auth;
38use crate::config::provider::ProviderConfig;
39use crate::config::Config;
40
41const DEFAULT_CODINGPLAN_LLM_BASE_URL: &str = "https://llm-api.atomgit.com/v1";
53
54fn codingplan_llm_base_url() -> String {
73 use std::sync::OnceLock;
74 static URL: OnceLock<String> = OnceLock::new();
75 URL.get_or_init(|| {
76 std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
77 .ok()
78 .map(|v| v.trim().trim_end_matches('/').to_string())
79 .filter(|v| !v.is_empty())
80 .unwrap_or_else(|| DEFAULT_CODINGPLAN_LLM_BASE_URL.to_string())
81 })
82 .clone()
83}
84
85const PROVIDER_TYPE: &str = "openai";
87
88const CONTEXT_WINDOW: usize = 64_000;
92
93const PROVIDER_PREFIX: &str = "AtomGit";
95
96#[derive(Debug, Clone)]
101pub enum StepResult<T> {
102 Ok(T),
104 Skipped(String),
107 Err(String),
109}
110
111impl<T> StepResult<T> {
112 pub fn is_err(&self) -> bool {
113 matches!(self, StepResult::Err(_))
114 }
115 pub fn is_ok_or_skipped(&self) -> bool {
116 !self.is_err()
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum VisionPreprocessorOutcome {
126 UnchangedNone,
128 UserSupplied(String),
131 AutoSet(String),
135 Cleared,
139}
140
141impl SetupReport {
142 pub fn render(&self) -> String {
146 use crate::i18n::{t, Msg};
147
148 let mut out = String::new();
149 out.push_str(&t(Msg::CpSetupHeader));
150
151 match &self.login {
153 StepResult::Ok(info) => {
154 let who = info.display_name.as_deref().unwrap_or(&info.username);
155 let email = info.email.as_deref().unwrap_or("—");
156 out.push_str(&t(Msg::CpLoggedIn {
157 who,
158 username: &info.username,
159 email,
160 }));
161 }
162 StepResult::Skipped(reason) => {
163 out.push_str(&t(Msg::CpStepSkipped { reason }));
164 }
165 StepResult::Err(msg) => {
166 out.push_str(&t(Msg::CpLoginFailed { error: msg }));
167 }
168 }
169
170 if !self.claim_attempts.is_empty() {
177 for attempt in &self.claim_attempts {
178 let tier = attempt.tier.as_str();
179 match &attempt.outcome {
180 TierOutcome::Claimed { .. } => {
181 out.push_str(&t(Msg::CpClaimTierSucceeded { tier }));
182 }
183 TierOutcome::AlreadyHeld { .. } => {
184 out.push_str(&t(Msg::CpClaimTierAlreadyHeld { tier }));
185 }
186 TierOutcome::Refused { message } => {
187 let reason = if message.is_empty() {
188 "(no reason given)"
194 } else {
195 message.as_str()
196 };
197 out.push_str(&t(Msg::CpClaimTierFailed { tier, reason }));
198 }
199 TierOutcome::Errored { error } => {
200 let reason = truncate_inline(error, 150);
205 out.push_str(&t(Msg::CpClaimTierFailed {
206 tier,
207 reason: &reason,
208 }));
209 }
210 }
211 }
212 } else {
213 match &self.claim {
214 StepResult::Ok(info) => {
215 let fallback = t(Msg::CpClaimSuccessFallback);
216 let message = if info.message.is_empty() {
217 fallback.as_ref()
218 } else {
219 info.message.as_str()
220 };
221 out.push_str(&t(Msg::CpClaimed {
222 message,
223 plan_type: info.plan_type.as_str(),
224 }));
225 }
226 StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
227 }
229 StepResult::Skipped(reason) => {
230 out.push_str(&t(Msg::CpAlreadyClaimed { reason }));
231 }
232 StepResult::Err(msg) => {
233 out.push_str(&t(Msg::CpClaimFailed { error: msg }));
234 }
235 }
236 }
237
238 match &self.models {
243 StepResult::Ok(info) => {
244 let count = info.provider_names.len();
245 let plural_s = if count == 1 { "" } else { "s" };
246 out.push_str(&t(Msg::CpAddedProviders { count, plural_s }));
247 let registered: std::collections::HashSet<&str> =
252 info.display_names.iter().map(|s| s.as_str()).collect();
253 let locked: Vec<&ModelEntry> = info
267 .all_models
268 .iter()
269 .filter(|m| !m.plan_available && !registered.contains(m.display_model_name.as_str()))
270 .collect();
271 for m in &locked {
272 out.push_str(&t(Msg::CpLocked {
273 name: &m.display_model_name,
274 }));
275 }
276 let default_suffix_cow = t(Msg::CpDefaultSuffix);
277 for (pname, model) in info.provider_names.iter().zip(info.display_names.iter()) {
278 let suffix = if pname == &info.default_provider {
279 default_suffix_cow.as_ref()
280 } else {
281 ""
282 };
283 out.push_str(&t(Msg::CpProviderRow {
284 provider: pname,
285 model,
286 default_suffix: suffix,
287 }));
288 }
289 match &info.vision_preprocessor {
291 VisionPreprocessorOutcome::AutoSet(k) => {
292 out.push_str(&t(Msg::CpVisionAuto { kind: k }));
293 }
294 VisionPreprocessorOutcome::UserSupplied(k) => {
295 out.push_str(&t(Msg::CpVisionUserSupplied { kind: k }));
296 }
297 VisionPreprocessorOutcome::Cleared => {
298 out.push_str(&t(Msg::CpVisionCleared));
299 }
300 VisionPreprocessorOutcome::UnchangedNone => {
301 }
304 }
305 }
306 StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
307 }
309 StepResult::Skipped(reason) => {
310 out.push_str(&t(Msg::CpModelsSkipped { reason }));
311 }
312 StepResult::Err(msg) => {
313 out.push_str(&t(Msg::CpModelsFailed { error: msg }));
314 }
315 }
316
317 match &self.status {
319 StepResult::Ok(s) => {
320 out.push_str(&t(Msg::CpStatusHeader));
321 if let Some(plan) = &s.codingplan_free {
322 if plan.expires_at.is_empty() {
323 out.push_str(&t(Msg::CpPlanPending { plan: &plan.plan_name }));
328 } else {
329 out.push_str(&t(Msg::CpPlanActive {
330 plan: &plan.plan_name,
331 expires_at: &plan.expires_at,
332 remaining_days: plan.remaining_days,
333 total_days: plan.total_days,
334 }));
335 }
336 }
337 if let Some(u) = &s.current_usage {
338 out.push_str(&t(Msg::CpUsageLine {
339 usage: &u.display_desc(),
340 reset_at: &u.reset_at_display,
341 duration: &format_duration_secs(u.seconds_until_reset),
342 }));
343 }
344 if s.window_quota_exhausted {
345 if let Some(hint) = &s.window_quota_hint {
346 out.push_str(&t(Msg::CpWindowQuotaHint { hint }));
347 } else {
348 out.push_str(&t(Msg::CpWindowQuotaExhausted));
349 }
350 }
351 }
352 StepResult::Skipped(reason) if reason == CASCADE_FROM_UPSTREAM_FAIL => {
353 }
355 StepResult::Skipped(reason) => {
356 out.push_str(&t(Msg::CpStatusFetchSkipped { reason }));
357 }
358 StepResult::Err(msg) => {
359 out.push_str(&t(Msg::CpStatusFetchFailed {
365 error: &truncate_inline(msg, 150),
366 }));
367 }
368 }
369
370 out
371 }
372
373 pub fn should_persist_config(&self) -> bool {
390 self.login.is_ok_or_skipped()
391 && self.claim.is_ok_or_skipped()
392 && self.models.is_ok_or_skipped()
393 }
394}
395
396#[derive(Debug, Clone)]
400pub struct SetupReport {
401 pub login: StepResult<LoginInfo>,
402 pub claim: StepResult<ClaimInfo>,
403 pub claim_attempts: Vec<TierAttempt>,
412 pub models: StepResult<ModelsInfo>,
413 pub status: StepResult<StatusResponse>,
414 pub auth_expired: bool,
424}
425
426#[derive(Debug, Clone)]
427pub struct LoginInfo {
428 pub username: String,
429 pub display_name: Option<String>,
430 pub email: Option<String>,
431}
432
433#[derive(Debug, Clone)]
434pub struct ClaimInfo {
435 pub message: String,
436 pub duplicate: bool,
439 pub plan_type: PlanType,
445}
446
447#[derive(Debug, Clone)]
453pub enum TierOutcome {
454 Claimed { message: String },
456 AlreadyHeld { message: String },
459 Refused { message: String },
462 Errored { error: String },
464}
465
466#[derive(Debug, Clone)]
467pub struct TierAttempt {
468 pub tier: PlanType,
469 pub outcome: TierOutcome,
470}
471
472#[derive(Debug, Clone)]
473pub struct ModelsInfo {
474 pub display_names: Vec<String>,
478 pub provider_names: Vec<String>,
480 pub default_provider: String,
482 pub vision_preprocessor: VisionPreprocessorOutcome,
485 pub all_models: Vec<ModelEntry>,
490}
491
492pub fn run(
499 config: &mut Config,
500 tel: Option<&Arc<atomcode_telemetry::Telemetry>>,
501) -> Result<SetupReport> {
502 let login = step_login(tel);
504 if login.is_err() {
505 if let Some(t) = tel {
507 t.track(atomcode_telemetry::Event::TakeCodingplan {
508 type_: atomcode_telemetry::CodingplanResult::Fail,
509 error_kind: Some(atomcode_telemetry::CodingplanErrorKind::AuthError),
510 error_data: Some(serde_json::json!({
511 "step": "login",
512 "message": "Not logged in",
513 }).to_string()),
514 });
515 }
516 return Ok(SetupReport {
521 login,
522 claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
523 claim_attempts: Vec::new(),
524 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
525 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
526 auth_expired: false,
527 });
528 }
529
530 let (claim, claim_attempts, claim_auth_expired) = step_claim();
532 if claim.is_err() {
533 return Ok(SetupReport {
539 login,
540 claim,
541 claim_attempts,
542 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
543 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
544 auth_expired: claim_auth_expired,
545 });
546 }
547
548 let plan_type_for_models = match &claim {
559 StepResult::Ok(info) => info.plan_type,
560 _ => PlanType::Max,
561 };
562
563 let (models, models_auth_expired) = step_models_and_register(config, plan_type_for_models);
565 if models.is_err() {
566 if let Some(t) = tel {
567 t.track(atomcode_telemetry::Event::TakeCodingplan {
568 type_: atomcode_telemetry::CodingplanResult::Fail,
569 error_kind: Some(atomcode_telemetry::CodingplanErrorKind::NetworkError),
570 error_data: Some(serde_json::json!({
571 "step": "models",
572 "message": "Failed to fetch model list",
573 }).to_string()),
574 });
575 }
576 return Ok(SetupReport {
580 login,
581 claim,
582 claim_attempts,
583 models,
584 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
585 auth_expired: models_auth_expired,
586 });
587 }
588
589 let (status, status_auth_expired) = step_status();
593
594 if let Some(t) = tel {
596 t.track(atomcode_telemetry::Event::TakeCodingplan {
597 type_: atomcode_telemetry::CodingplanResult::Success,
598 error_kind: None,
599 error_data: Some(serde_json::json!({
600 "step": null,
601 }).to_string()),
602 });
603 }
604
605 Ok(SetupReport {
606 login,
607 claim,
608 claim_attempts,
609 models,
610 status,
611 auth_expired: status_auth_expired,
612 })
613}
614
615const CASCADE_FROM_UPSTREAM_FAIL: &str = "__cascade_upstream_fail__";
620
621fn step_login(tel: Option<&Arc<atomcode_telemetry::Telemetry>>) -> StepResult<LoginInfo> {
622 if auth::is_logged_in() {
623 if let Some(info) = auth::get_stored_auth() {
628 let display = match info.user.name.as_deref() {
629 Some(name) if !name.is_empty() && name != info.user.username => {
630 format!("{}({})", name, info.user.username)
631 }
632 _ => info.user.username.clone(),
633 };
634 return StepResult::Skipped(format!("already logged in as {}", display));
635 }
636 return StepResult::Skipped("already logged in".into());
639 }
640 match auth::login(tel).and_then(|a| auth::save_auth(&a).map(|_| a)) {
644 Ok(auth_info) => StepResult::Ok(LoginInfo {
645 username: auth_info.user.username.clone(),
646 display_name: auth_info.user.name.clone(),
647 email: auth_info.user.email.clone(),
648 }),
649 Err(e) => StepResult::Err(format!("login failed: {:#}", e)),
650 }
651}
652
653fn step_claim() -> (StepResult<ClaimInfo>, Vec<TierAttempt>, bool) {
687 let client = match Client::from_stored_auth() {
688 Ok(c) => c,
689 Err(e) => {
690 let auth_expired = is_auth_expired(&e);
691 return (
692 StepResult::Err(format!("build client: {:#}", e)),
693 Vec::new(),
694 auth_expired,
695 );
696 }
697 };
698 let mut attempts: Vec<TierAttempt> = Vec::with_capacity(PlanType::CASCADE_ORDER.len());
699 let mut last_msg = String::new();
700 for &tier in PlanType::CASCADE_ORDER {
701 match client.claim_v2(tier) {
702 Ok(resp) => {
703 if resp.duplicate {
704 attempts.push(TierAttempt {
705 tier,
706 outcome: TierOutcome::AlreadyHeld {
707 message: resp.message.clone(),
708 },
709 });
710 let skipped = StepResult::Skipped(if resp.message.is_empty() {
711 format!(
712 "already claimed (or under review) — using {}",
713 tier.as_str()
714 )
715 } else {
716 format!("{} ({})", resp.message, tier.as_str())
717 });
718 return (skipped, attempts, false);
719 }
720 if resp.success {
721 attempts.push(TierAttempt {
722 tier,
723 outcome: TierOutcome::Claimed {
724 message: resp.message.clone(),
725 },
726 });
727 let ok = StepResult::Ok(ClaimInfo {
728 message: if resp.message.is_empty() {
729 format!("claimed {}", tier.as_str())
730 } else {
731 resp.message
732 },
733 duplicate: false,
734 plan_type: tier,
735 });
736 return (ok, attempts, false);
737 }
738 attempts.push(TierAttempt {
741 tier,
742 outcome: TierOutcome::Refused {
743 message: resp.message.clone(),
744 },
745 });
746 last_msg = if resp.message.is_empty() {
747 format!("{} claim refused", tier.as_str())
748 } else {
749 format!("{}: {}", tier.as_str(), resp.message)
750 };
751 }
752 Err(e) => {
753 let auth_expired = is_auth_expired(&e);
758 let err_text = format!("{:#}", e);
759 attempts.push(TierAttempt {
760 tier,
761 outcome: TierOutcome::Errored {
762 error: err_text.clone(),
763 },
764 });
765 return (
766 StepResult::Err(format!("claim {} request: {}", tier.as_str(), err_text)),
767 attempts,
768 auth_expired,
769 );
770 }
771 }
772 }
773 let overall = StepResult::Err(if last_msg.is_empty() {
774 "claim failed at every tier (Max/Pro/Lite)".into()
775 } else {
776 format!("claim failed at every tier — {}", last_msg)
777 });
778 (overall, attempts, false)
779}
780
781fn step_models_and_register(
782 config: &mut Config,
783 plan_type: PlanType,
784) -> (StepResult<ModelsInfo>, bool) {
785 let client = match Client::from_stored_auth() {
786 Ok(c) => c,
787 Err(e) => {
788 let auth_expired = is_auth_expired(&e);
789 return (
790 StepResult::Err(format!("build client: {:#}", e)),
791 auth_expired,
792 );
793 }
794 };
795 let all_models = match client.list_models_v2(plan_type) {
796 Ok(v) => v,
797 Err(e) => {
798 let auth_expired = is_auth_expired(&e);
799 return (
800 StepResult::Err(format!("list models-v2: {:#}", e)),
801 auth_expired,
802 );
803 }
804 };
805 if all_models.is_empty() {
806 return (
807 StepResult::Err(
808 "server returned an empty model list — cannot set up any provider".into(),
809 ),
810 false,
811 );
812 }
813
814 let available: Vec<&ModelEntry> = all_models.iter().filter(|m| m.plan_available).collect();
820 if available.is_empty() {
821 return (
822 StepResult::Err(format!(
823 "no models available on plan {} — server returned {} locked entries",
824 plan_type.as_str(),
825 all_models.len()
826 )),
827 false,
828 );
829 }
830
831 let stale: Vec<String> = config
833 .providers
834 .keys()
835 .filter(|k| is_codingplan_provider_name(k))
836 .cloned()
837 .collect();
838 for k in stale {
839 config.providers.remove(&k);
840 }
841
842 let names: Vec<String> = available
843 .iter()
844 .map(|m| m.display_model_name.clone())
845 .collect();
846 let provider_names = provider_names_for(&names);
847 let default_provider = provider_names
848 .first()
849 .cloned()
850 .unwrap_or_else(|| PROVIDER_PREFIX.to_string());
851
852 for (pname, m) in provider_names.iter().zip(available.iter()) {
853 let pc = build_codingplan_provider(m);
854 config.providers.insert(pname.clone(), pc);
855 }
856 config.default_provider = default_provider.clone();
857
858 let vl_idx = names
865 .iter()
866 .position(|n| crate::provider::model_name_suggests_vision(n));
867 let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
868
869 let vision_preprocessor = {
870 let current = config.vision_preprocessor_provider.clone();
871 let user_supplied_non_atomgit = current
872 .as_deref()
873 .map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
874 .unwrap_or(false);
875
876 if user_supplied_non_atomgit {
877 VisionPreprocessorOutcome::UserSupplied(current.unwrap())
878 } else {
879 match new_vl_key {
880 Some(k) => {
881 config.vision_preprocessor_provider = Some(k.clone());
882 VisionPreprocessorOutcome::AutoSet(k)
883 }
884 None => {
885 if current.is_some() {
886 config.vision_preprocessor_provider = None;
887 VisionPreprocessorOutcome::Cleared
888 } else {
889 VisionPreprocessorOutcome::UnchangedNone
890 }
891 }
892 }
893 }
894 };
895
896 (
897 StepResult::Ok(ModelsInfo {
898 display_names: names,
899 provider_names,
900 default_provider,
901 vision_preprocessor,
902 all_models,
903 }),
904 false,
905 )
906}
907
908fn step_status() -> (StepResult<StatusResponse>, bool) {
909 let client = match Client::from_stored_auth() {
910 Ok(c) => c,
911 Err(e) => {
912 let auth_expired = is_auth_expired(&e);
913 return (
914 StepResult::Err(format!("build client: {:#}", e)),
915 auth_expired,
916 );
917 }
918 };
919 match client.status_v2() {
920 Ok(s) => (StepResult::Ok(s), false),
921 Err(e) => {
922 let auth_expired = is_auth_expired(&e);
923 (
924 StepResult::Err(format!("status-v2: {:#}", e)),
925 auth_expired,
926 )
927 }
928 }
929}
930
931fn truncate_inline(msg: &str, max: usize) -> String {
936 if msg.chars().count() <= max {
937 return msg.to_string();
938 }
939 let mut out: String = msg.chars().take(max).collect();
940 out.push('…');
941 out
942}
943
944fn format_duration_secs(secs: i64) -> String {
949 if secs < 0 {
950 return "—".into();
951 }
952 let s = secs as u64;
953 if s < 60 {
954 return format!("{}s", s);
955 }
956 let (m, sr) = (s / 60, s % 60);
957 if m < 60 {
958 return if sr == 0 { format!("{}m", m) } else { format!("{}m {}s", m, sr) };
959 }
960 let (h, mr) = (m / 60, m % 60);
961 if h < 24 {
962 return if mr == 0 { format!("{}h", h) } else { format!("{}h {}m", h, mr) };
963 }
964 let (d, hr) = (h / 24, h % 24);
965 if hr == 0 { format!("{}d", d) } else { format!("{}d {}h", d, hr) }
966}
967
968fn provider_names_for(model_names: &[String]) -> Vec<String> {
972 if model_names.len() == 1 {
973 vec![PROVIDER_PREFIX.to_string()]
974 } else {
975 model_names
976 .iter()
977 .map(|m| format!("{}-{}", PROVIDER_PREFIX, sanitize_model_for_name(m)))
978 .collect()
979 }
980}
981
982fn sanitize_model_for_name(model: &str) -> String {
986 model.replace('/', "-")
987}
988
989fn is_codingplan_provider_name(name: &str) -> bool {
993 name == PROVIDER_PREFIX || name.starts_with(&format!("{}-", PROVIDER_PREFIX))
994}
995
996fn build_codingplan_provider(entry: &ModelEntry) -> ProviderConfig {
1006 ProviderConfig {
1007 provider_type: entry
1008 .provider_type
1009 .clone()
1010 .filter(|s| !s.is_empty())
1011 .unwrap_or_else(|| PROVIDER_TYPE.to_string()),
1012 api_key: None,
1013 model: entry.display_model_name.clone(),
1014 base_url: Some(
1015 entry
1016 .base_url
1017 .clone()
1018 .filter(|s| !s.is_empty())
1019 .unwrap_or_else(codingplan_llm_base_url),
1020 ),
1021 system_prompt: None,
1022 user_agent: None,
1023 context_window: entry
1027 .context_window
1028 .filter(|n| *n > 0)
1029 .unwrap_or(CONTEXT_WINDOW),
1030 max_tokens: None,
1031 thinking_type: None,
1032 thinking_keep: None,
1033 reasoning_history: None,
1034 thinking_enabled: None,
1035 thinking_budget: None,
1036 skip_tls_verify: false,
1037 ephemeral: false,
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use std::collections::HashMap;
1045
1046 fn entry(display_model_name: &str) -> super::super::types::ModelEntry {
1055 super::super::types::ModelEntry {
1056 display_model_name: display_model_name.to_string(),
1057 plan_available: true,
1058 ..Default::default()
1059 }
1060 }
1061
1062 fn blank_config() -> Config {
1063 Config {
1064 default_provider: String::new(),
1065 default_workdir: None,
1066 providers: HashMap::new(),
1067 datalog: Default::default(),
1068 auto_update: true,
1069 notifications: Default::default(),
1070 telemetry: Default::default(),
1071 lsp: Default::default(),
1072 auto_commit: false,
1073 subagent: Default::default(),
1074 vision_preprocessor_provider: None,
1075 language: None,
1076 ui: Default::default(),
1077 plugin: Default::default(),
1078 }
1079 }
1080
1081 #[test]
1082 fn single_model_uses_bare_prefix() {
1083 let names = vec!["moonshotai/Kimi-K2-Instruct".into()];
1084 let p = provider_names_for(&names);
1085 assert_eq!(p, vec!["AtomGit".to_string()]);
1086 }
1087
1088 #[test]
1089 fn multiple_models_expand_to_prefix_suffixes() {
1090 let names = vec![
1091 "moonshotai/Kimi-K2-Instruct".into(),
1092 "anthropic/claude-3.5-sonnet".into(),
1093 "openai/gpt-5".into(),
1094 ];
1095 let p = provider_names_for(&names);
1096 assert_eq!(
1097 p,
1098 vec![
1099 "AtomGit-moonshotai-Kimi-K2-Instruct".to_string(),
1100 "AtomGit-anthropic-claude-3.5-sonnet".to_string(),
1101 "AtomGit-openai-gpt-5".to_string(),
1102 ]
1103 );
1104 }
1105
1106 #[test]
1107 fn sanitize_replaces_slash_only() {
1108 assert_eq!(
1110 sanitize_model_for_name("anthropic/claude-3.5-sonnet"),
1111 "anthropic-claude-3.5-sonnet"
1112 );
1113 }
1114
1115 #[test]
1116 fn is_codingplan_name_matches_prefix_and_exact() {
1117 assert!(is_codingplan_provider_name("AtomGit"));
1118 assert!(is_codingplan_provider_name("AtomGit-foo"));
1119 assert!(is_codingplan_provider_name("AtomGit-moonshotai-Kimi-K2"));
1120 assert!(!is_codingplan_provider_name("AtomGitPlus"));
1121 assert!(!is_codingplan_provider_name("atomgit")); assert!(!is_codingplan_provider_name("claude"));
1123 }
1124
1125 #[test]
1126 fn step_models_wipes_stale_atomgit_entries() {
1127 let mut config = blank_config();
1132 config.providers.insert(
1133 "AtomGit".to_string(),
1134 build_codingplan_provider(&entry("stale-MiniMax")),
1135 );
1136 config.providers.insert(
1137 "AtomGit-legacy".to_string(),
1138 build_codingplan_provider(&entry("another-stale")),
1139 );
1140 config.providers.insert(
1141 "claude".to_string(),
1142 build_codingplan_provider(&entry("anthropic/claude-3.5")),
1143 );
1144
1145 let names = vec!["meta-llama/Llama-3-70B".to_string()];
1148 let stale: Vec<String> = config
1149 .providers
1150 .keys()
1151 .filter(|k| is_codingplan_provider_name(k))
1152 .cloned()
1153 .collect();
1154 for k in stale {
1155 config.providers.remove(&k);
1156 }
1157 let provider_names = provider_names_for(&names);
1158 for (pname, m) in provider_names.iter().zip(names.iter()) {
1159 config
1160 .providers
1161 .insert(pname.clone(), build_codingplan_provider(&entry(m)));
1162 }
1163 config.default_provider = provider_names[0].clone();
1164
1165 assert_eq!(config.providers.len(), 2, "claude + one fresh AtomGit");
1166 assert!(
1167 config.providers.contains_key("claude"),
1168 "unrelated entry kept"
1169 );
1170 assert!(
1171 config.providers.contains_key("AtomGit"),
1172 "fresh AtomGit added"
1173 );
1174 assert!(
1175 !config.providers.contains_key("AtomGit-legacy"),
1176 "stale removed"
1177 );
1178 let fresh = &config.providers["AtomGit"];
1179 assert_eq!(fresh.model, "meta-llama/Llama-3-70B");
1180 assert_eq!(
1181 fresh.base_url.as_deref(),
1182 Some(codingplan_llm_base_url().as_str())
1183 );
1184 assert_eq!(fresh.provider_type, PROVIDER_TYPE);
1185 assert_eq!(config.default_provider, "AtomGit");
1186 }
1187
1188 #[test]
1189 fn codingplan_llm_base_url_defaults_to_new_signed_gateway() {
1190 let actual = codingplan_llm_base_url();
1204 let env_override = std::env::var("ATOMCODE_CODINGPLAN_LLM_BASE_URL")
1205 .ok()
1206 .map(|v| v.trim().trim_end_matches('/').to_string())
1207 .filter(|v| !v.is_empty());
1208 if let Some(want) = env_override {
1209 assert_eq!(actual, want, "env override must win when set");
1210 } else {
1211 assert_eq!(
1212 actual, "https://llm-api.atomgit.com/v1",
1213 "default must point at the new signed gateway (NOT legacy api-ai.gitcode.com); \
1214 otherwise codingplan signing never engages"
1215 );
1216 }
1217 }
1218
1219 #[test]
1220 fn build_provider_uses_canonical_defaults() {
1221 let p = build_codingplan_provider(&entry("foo/bar"));
1226 assert_eq!(p.provider_type, "openai");
1227 assert_eq!(
1228 p.base_url.as_deref(),
1229 Some(codingplan_llm_base_url().as_str())
1230 );
1231 assert_eq!(p.context_window, 64_000);
1232 assert!(
1233 p.api_key.is_none(),
1234 "token loaded at runtime from auth.toml"
1235 );
1236 assert!(!p.ephemeral);
1237 }
1238
1239 #[test]
1240 fn build_provider_uses_server_overrides_when_present() {
1241 let e = super::super::types::ModelEntry {
1245 id: 2052994857682014210,
1246 is_infinity: 2,
1247 is_atomcode_exclusive: 1,
1248 display_model_name: "GLM-5.1".into(),
1249 base_url: Some("https://custom.example.com/v1".into()),
1250 provider_type: Some("claude".into()),
1251 context_window: Some(128_000),
1252 plan_available: true,
1253 };
1254 let p = build_codingplan_provider(&e);
1255 assert_eq!(p.model, "GLM-5.1");
1256 assert_eq!(p.provider_type, "claude");
1257 assert_eq!(p.base_url.as_deref(), Some("https://custom.example.com/v1"));
1258 assert_eq!(p.context_window, 128_000);
1259 }
1260
1261 #[test]
1262 fn build_provider_treats_empty_or_zero_overrides_as_missing() {
1263 let e = super::super::types::ModelEntry {
1267 display_model_name: "weird".into(),
1268 base_url: Some(String::new()),
1269 provider_type: Some(String::new()),
1270 context_window: Some(0),
1271 plan_available: true,
1272 ..Default::default()
1273 };
1274 let p = build_codingplan_provider(&e);
1275 assert_eq!(p.provider_type, "openai");
1276 assert_eq!(
1277 p.base_url.as_deref(),
1278 Some(codingplan_llm_base_url().as_str())
1279 );
1280 assert_eq!(p.context_window, 64_000);
1281 }
1282
1283 #[test]
1284 fn model_entry_deserialises_new_wire_shape() {
1285 let raw = r#"[{
1288 "id": 2052994857682014210,
1289 "is_infinity": 2,
1290 "is_atomcode_exclusive": 1,
1291 "display_model_name": "GLM-5.1",
1292 "base_url": "https://api-ai.gitcode.com/v1",
1293 "type": "openai",
1294 "context_window": 64000,
1295 "plan_available": true
1296 }]"#;
1297 let list: Vec<super::super::types::ModelEntry> =
1298 serde_json::from_str(raw).expect("payload deserialises");
1299 assert_eq!(list.len(), 1);
1300 let m = &list[0];
1301 assert_eq!(m.id, 2052994857682014210);
1302 assert_eq!(m.is_infinity, 2);
1303 assert_eq!(m.is_atomcode_exclusive, 1);
1304 assert_eq!(m.display_model_name, "GLM-5.1");
1305 assert_eq!(m.base_url.as_deref(), Some("https://api-ai.gitcode.com/v1"));
1306 assert_eq!(m.provider_type.as_deref(), Some("openai"));
1307 assert_eq!(m.context_window, Some(64_000));
1308 assert!(m.plan_available);
1309 }
1310
1311 #[test]
1312 fn model_entry_deserialises_legacy_wire_shape() {
1313 let raw = r#"[{
1317 "id": 1,
1318 "is_atomcode_exclusive": 0,
1319 "display_model_name": "legacy/model",
1320 "plan_available": true
1321 }]"#;
1322 let list: Vec<super::super::types::ModelEntry> =
1323 serde_json::from_str(raw).expect("legacy payload deserialises");
1324 let m = &list[0];
1325 assert_eq!(m.display_model_name, "legacy/model");
1326 assert!(m.base_url.is_none());
1327 assert!(m.provider_type.is_none());
1328 assert!(m.context_window.is_none());
1329 assert_eq!(m.is_infinity, 0);
1330 }
1331
1332 #[test]
1335 fn render_happy_path_has_all_checkmarks() {
1336 let report = SetupReport {
1337 login: StepResult::Ok(LoginInfo {
1338 username: "theo".into(),
1339 display_name: Some("Theo".into()),
1340 email: Some("theo@example.com".into()),
1341 }),
1342 claim: StepResult::Ok(ClaimInfo {
1343 message: "领取成功".into(),
1344 duplicate: false,
1345 plan_type: PlanType::Max,
1346 }),
1347 claim_attempts: Vec::new(),
1348 models: StepResult::Ok(ModelsInfo {
1349 display_names: vec!["moonshotai/Kimi-K2-Instruct".into()],
1350 provider_names: vec!["AtomGit".into()],
1351 default_provider: "AtomGit".into(),
1352 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1353 all_models: vec![],
1354 }),
1355 status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
1356 codingplan_free: Some(crate::coding_plan::types::PlanInfo {
1357 plan_name: "CodingPlan Free".into(),
1358 status: 1,
1359 claimed_at: "2026-04-22".into(),
1360 expires_at: "2026-05-22".into(),
1361 remaining_days: 29,
1362 total_days: 30,
1363 apply_id: 1,
1364 }),
1365 current_usage: Some(crate::coding_plan::types::UsageInfo {
1366 placeholder: false,
1367 window_token_limit: 50000,
1368 window_tokens_used: 0,
1369 usage_percent: 0.0,
1370 window_hours: 1,
1371 reset_at: "2026-04-23T12:13:14".into(),
1372 reset_at_display: "12:13".into(),
1373 seconds_until_reset: 693,
1374 reset_label: String::new(),
1375 usage_status_desc: String::new(),
1376 }),
1377 audit_status: 1,
1378 expires_at: Some("2026-05-22".into()),
1379 window_quota_exhausted: false,
1380 window_quota_hint: None,
1381 }),
1382 auth_expired: false,
1383 };
1384 let out = report.render();
1385 assert!(out.contains("✓ Logged in as Theo"));
1386 assert!(out.contains("theo@example.com"));
1387 assert!(out.contains("CodingPlan claimed"));
1388 assert!(out.contains("Kimi-K2-Instruct"));
1389 assert!(out.contains("AtomGit"));
1390 assert!(out.contains("(default)"));
1391 assert!(out.contains("CodingPlan Free"));
1392 assert!(out.contains("12:13"));
1393 assert!(report.should_persist_config());
1394 }
1395
1396 #[test]
1399 fn render_claim_duplicate_renders_as_success() {
1400 let report = SetupReport {
1401 login: StepResult::Skipped("already logged in as theo".into()),
1402 claim: StepResult::Skipped("already claimed / in review".into()),
1403 claim_attempts: Vec::new(),
1404 models: StepResult::Ok(ModelsInfo {
1405 display_names: vec!["a/b".into()],
1406 provider_names: vec!["AtomGit".into()],
1407 default_provider: "AtomGit".into(),
1408 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1409 all_models: vec![],
1410 }),
1411 status: StepResult::Err("request timeout".into()),
1412 auth_expired: false,
1413 };
1414 let out = report.render();
1415 assert!(out.contains("✓ already logged in"));
1416 assert!(out.contains("already claimed"));
1417 assert!(!out.contains("✗ CodingPlan claim"), "duplicate ≠ failure");
1418 assert!(out.contains("⚠ Status fetch failed"));
1420 assert!(!out.contains("✗ Status"));
1421 assert!(report.should_persist_config());
1423 }
1424
1425 #[test]
1433 fn render_status_pending_activation_omits_zero_expiry() {
1434 let report = SetupReport {
1435 login: StepResult::Skipped("already logged in".into()),
1436 claim: StepResult::Ok(ClaimInfo {
1437 message: "claimed".into(),
1438 duplicate: false,
1439 plan_type: PlanType::Max,
1440 }),
1441 claim_attempts: Vec::new(),
1442 models: StepResult::Ok(ModelsInfo {
1443 display_names: vec!["a/b".into()],
1444 provider_names: vec!["AtomGit".into()],
1445 default_provider: "AtomGit".into(),
1446 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1447 all_models: vec![],
1448 }),
1449 status: StepResult::Ok(crate::coding_plan::types::StatusResponse {
1450 codingplan_free: Some(crate::coding_plan::types::PlanInfo {
1451 plan_name: "CodingPlan Free".into(),
1452 status: 0,
1453 claimed_at: String::new(),
1454 expires_at: String::new(),
1455 remaining_days: 0,
1456 total_days: 0,
1457 apply_id: 0,
1458 }),
1459 current_usage: None,
1460 audit_status: 0,
1461 expires_at: None,
1462 window_quota_exhausted: false,
1463 window_quota_hint: None,
1464 }),
1465 auth_expired: false,
1466 };
1467 let out = report.render();
1468 assert!(out.contains("Plan: CodingPlan Free"), "plan name still shown: {}", out);
1469 assert!(
1470 out.contains("pending activation"),
1471 "must surface pending state to user: {}",
1472 out
1473 );
1474 assert!(
1475 !out.contains("(0d / 0d"),
1476 "bogus zero countdown must not render: {}",
1477 out
1478 );
1479 assert!(
1480 !out.contains("expires ("),
1481 "empty expires-date with double space must not render: {}",
1482 out
1483 );
1484 }
1485
1486 #[test]
1490 fn render_login_failed_blocks_persist_and_suppresses_cascade() {
1491 let report = SetupReport {
1492 login: StepResult::Err("browser handshake timed out".into()),
1493 claim: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1494 claim_attempts: Vec::new(),
1495 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1496 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1497 auth_expired: false,
1498 };
1499 let out = report.render();
1500 assert!(out.contains("✗ Login failed"));
1501 assert!(!out.contains("CodingPlan claim"), "no cascade claim row on login fail");
1503 assert!(!out.contains("Models step"), "no cascade models row on login fail");
1504 assert!(!out.contains("Status fetch"), "no cascade status row on login fail");
1505 assert!(
1507 !report.should_persist_config(),
1508 "don't write config on login failure"
1509 );
1510 }
1511
1512 #[test]
1523 fn claim_err_blocks_persist() {
1524 let report = SetupReport {
1525 login: StepResult::Skipped("already logged in".into()),
1526 claim: StepResult::Err(
1527 "claim Pro request: claim-v2 returned 500 Internal Server Error \
1528 — Transaction rolled back because it has been marked as rollback-only"
1529 .into(),
1530 ),
1531 claim_attempts: Vec::new(),
1532 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1533 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1534 auth_expired: false,
1535 };
1536 assert!(
1537 !report.should_persist_config(),
1538 "claim Err must block save_and_reload — config rewrite was overwriting \
1539 manual edits between TUI startup and /codingplan",
1540 );
1541 let dup = SetupReport {
1545 login: StepResult::Skipped("already logged in".into()),
1546 claim: StepResult::Skipped("already claimed / using Max".into()),
1547 claim_attempts: Vec::new(),
1548 models: StepResult::Ok(ModelsInfo {
1549 display_names: vec!["a/b".into()],
1550 provider_names: vec!["AtomGit".into()],
1551 default_provider: "AtomGit".into(),
1552 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1553 all_models: vec![],
1554 }),
1555 status: StepResult::Err("status fetch timeout".into()),
1556 auth_expired: false,
1557 };
1558 assert!(
1559 dup.should_persist_config(),
1560 "duplicate-claim Skipped must still allow persist (it's the model-sync path)",
1561 );
1562 }
1563
1564 #[test]
1576 fn auth_expired_alone_does_not_change_persist_gate() {
1577 let allow = SetupReport {
1582 login: StepResult::Skipped("already logged in".into()),
1583 claim: StepResult::Skipped("already claimed".into()),
1584 claim_attempts: Vec::new(),
1585 models: StepResult::Ok(ModelsInfo {
1586 display_names: vec!["a/b".into()],
1587 provider_names: vec!["AtomGit".into()],
1588 default_provider: "AtomGit".into(),
1589 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1590 all_models: vec![],
1591 }),
1592 status: StepResult::Skipped("ok".into()),
1593 auth_expired: true,
1594 };
1595 assert!(
1596 allow.should_persist_config(),
1597 "auth_expired must not gate persist when every critical step \
1598 is ok/skipped — it's a side-channel for retry, not safety",
1599 );
1600
1601 let block = SetupReport {
1604 login: StepResult::Skipped("already logged in".into()),
1605 claim: StepResult::Err("auth failed".into()),
1606 claim_attempts: Vec::new(),
1607 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1608 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1609 auth_expired: true,
1610 };
1611 assert!(
1612 !block.should_persist_config(),
1613 "claim Err already blocks persist — auth_expired doesn't \
1614 relax it",
1615 );
1616 }
1617
1618 #[test]
1623 fn render_per_tier_cascade_shows_every_attempt() {
1624 let report = SetupReport {
1625 login: StepResult::Skipped("already logged in as Code_dh".into()),
1626 claim: StepResult::Ok(ClaimInfo {
1627 message: "claimed".into(),
1628 duplicate: false,
1629 plan_type: PlanType::Lite,
1630 }),
1631 claim_attempts: vec![
1632 TierAttempt {
1633 tier: PlanType::Max,
1634 outcome: TierOutcome::Refused {
1635 message: "额度已满".into(),
1636 },
1637 },
1638 TierAttempt {
1639 tier: PlanType::Pro,
1640 outcome: TierOutcome::Refused {
1641 message: "额度已满".into(),
1642 },
1643 },
1644 TierAttempt {
1645 tier: PlanType::Lite,
1646 outcome: TierOutcome::Claimed {
1647 message: "领取成功".into(),
1648 },
1649 },
1650 ],
1651 models: StepResult::Skipped("models step not exercised here".into()),
1652 status: StepResult::Skipped("status not exercised here".into()),
1653 auth_expired: false,
1654 };
1655 let out = report.render();
1656 assert!(
1660 out.contains("CodingPlan Max 领取失败 — 额度已满")
1661 || out.contains("CodingPlan Max claim failed — 额度已满"),
1662 "Max refusal row missing: {}",
1663 out
1664 );
1665 assert!(
1666 out.contains("CodingPlan Pro 领取失败 — 额度已满")
1667 || out.contains("CodingPlan Pro claim failed — 额度已满"),
1668 "Pro refusal row missing: {}",
1669 out
1670 );
1671 assert!(
1673 out.contains("CodingPlan Lite 领取成功")
1674 || out.contains("CodingPlan Lite claimed"),
1675 "Lite success row missing: {}",
1676 out
1677 );
1678 assert!(
1682 !out.contains("CodingPlan claimed"),
1683 "legacy claim-summary row must be suppressed when per-tier rows present: {}",
1684 out
1685 );
1686 }
1687
1688 #[test]
1692 fn render_per_tier_cascade_all_refused() {
1693 let report = SetupReport {
1694 login: StepResult::Skipped("already logged in".into()),
1695 claim: StepResult::Err(
1696 "claim failed at every tier — Lite: 暂无开放".into(),
1697 ),
1698 claim_attempts: vec![
1699 TierAttempt {
1700 tier: PlanType::Max,
1701 outcome: TierOutcome::Refused {
1702 message: "暂无开放".into(),
1703 },
1704 },
1705 TierAttempt {
1706 tier: PlanType::Pro,
1707 outcome: TierOutcome::Refused {
1708 message: "暂无开放".into(),
1709 },
1710 },
1711 TierAttempt {
1712 tier: PlanType::Lite,
1713 outcome: TierOutcome::Refused {
1714 message: "暂无开放".into(),
1715 },
1716 },
1717 ],
1718 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1719 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1720 auth_expired: false,
1721 };
1722 let out = report.render();
1723 for tier in &["Max", "Pro", "Lite"] {
1725 let zh = format!("CodingPlan {} 领取失败 — 暂无开放", tier);
1726 let en = format!("CodingPlan {} claim failed — 暂无开放", tier);
1727 assert!(
1728 out.contains(&zh) || out.contains(&en),
1729 "{} refusal row missing: {}",
1730 tier,
1731 out
1732 );
1733 }
1734 assert!(
1738 !out.contains("claim failed at every tier"),
1739 "legacy err-summary row must not appear: {}",
1740 out
1741 );
1742 assert!(
1744 !out.contains("Models step"),
1745 "cascade-from-claim-fail must hide models row: {}",
1746 out
1747 );
1748 }
1749
1750 #[test]
1756 fn render_per_tier_cascade_with_errored_tier_truncates_long_message() {
1757 let long_err = "x".repeat(500);
1758 let report = SetupReport {
1759 login: StepResult::Skipped("already logged in".into()),
1760 claim: StepResult::Err(format!("claim Max request: {}", long_err)),
1761 claim_attempts: vec![TierAttempt {
1762 tier: PlanType::Max,
1763 outcome: TierOutcome::Errored {
1764 error: long_err.clone(),
1765 },
1766 }],
1767 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1768 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1769 auth_expired: false,
1770 };
1771 let out = report.render();
1772 assert!(
1774 out.contains("CodingPlan Max 领取失败 —")
1775 || out.contains("CodingPlan Max claim failed —"),
1776 "Max errored row missing: {}",
1777 out
1778 );
1779 assert!(
1781 !out.contains(&long_err),
1782 "long error must be truncated, not pasted whole: {}",
1783 out
1784 );
1785 }
1786
1787 #[test]
1790 fn render_multi_model_lists_all_providers_with_default_mark() {
1791 let report = SetupReport {
1792 login: StepResult::Skipped("already logged in as theo".into()),
1793 claim: StepResult::Ok(ClaimInfo {
1794 message: String::new(),
1795 duplicate: false,
1796 plan_type: PlanType::Max,
1797 }),
1798 claim_attempts: Vec::new(),
1799 models: StepResult::Ok(ModelsInfo {
1800 display_names: vec![
1801 "moonshotai/Kimi-K2-Instruct".into(),
1802 "anthropic/claude-3.5-sonnet".into(),
1803 "openai/gpt-5".into(),
1804 ],
1805 provider_names: vec![
1806 "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
1807 "AtomGit-anthropic-claude-3.5-sonnet".into(),
1808 "AtomGit-openai-gpt-5".into(),
1809 ],
1810 default_provider: "AtomGit-moonshotai-Kimi-K2-Instruct".into(),
1811 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1812 all_models: vec![],
1813 }),
1814 status: StepResult::Err("status endpoint 500".into()),
1815 auth_expired: false,
1816 };
1817 let out = report.render();
1818 assert!(out.contains("Added 3 providers"));
1819 assert!(out.contains(
1820 "AtomGit-moonshotai-Kimi-K2-Instruct → moonshotai/Kimi-K2-Instruct (default)"
1821 ));
1822 assert!(
1823 out.contains("AtomGit-anthropic-claude-3.5-sonnet → anthropic/claude-3.5-sonnet\n")
1824 );
1825 assert!(
1826 !out.contains("anthropic/claude-3.5-sonnet (default)"),
1827 "only first is default"
1828 );
1829 }
1830
1831 #[test]
1835 fn render_claim_failed_suppresses_cascade_rows() {
1836 let report = SetupReport {
1837 login: StepResult::Skipped("already logged in as theo".into()),
1838 claim: StepResult::Err("今日codingplan申请额度已满,请明天再试".into()),
1839 claim_attempts: Vec::new(),
1840 models: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1841 status: StepResult::Skipped(CASCADE_FROM_UPSTREAM_FAIL.into()),
1842 auth_expired: false,
1843 };
1844 let out = report.render();
1845 assert!(out.contains("✗ CodingPlan claim failed"));
1846 assert!(out.contains("今日codingplan申请额度已满"));
1847 assert!(!out.contains("Models step skipped"), "no cascade row for models");
1849 assert!(!out.contains("Status fetch skipped"), "no cascade row for status");
1850 assert!(!out.contains("Added "), "must not say 'Added N providers' on claim fail");
1851 assert!(!out.contains("invalid type: null"));
1853 assert!(!out.contains("plan_name"));
1854 }
1855
1856 #[test]
1859 fn render_skipped_with_non_cascade_reason_still_shows() {
1860 let report = SetupReport {
1861 login: StepResult::Skipped("already logged in as theo".into()),
1862 claim: StepResult::Skipped("already claimed".into()),
1863 claim_attempts: Vec::new(),
1864 models: StepResult::Skipped("models cached locally".into()),
1865 status: StepResult::Skipped("server returned 503; using cached".into()),
1866 auth_expired: false,
1867 };
1868 let out = report.render();
1869 assert!(out.contains("Models step skipped — models cached locally"));
1870 assert!(out.contains("Status fetch skipped — server returned 503"));
1871 }
1872
1873 #[test]
1876 fn render_status_error_truncates_long_message() {
1877 let huge = format!(
1878 "status: parse status response (body: {}): invalid type",
1879 "x".repeat(1000),
1880 );
1881 let report = SetupReport {
1882 login: StepResult::Skipped("already logged in".into()),
1883 claim: StepResult::Ok(ClaimInfo {
1884 message: "ok".into(),
1885 duplicate: false,
1886 plan_type: PlanType::Max,
1887 }),
1888 claim_attempts: Vec::new(),
1889 models: StepResult::Ok(ModelsInfo {
1890 display_names: vec!["a/b".into()],
1891 provider_names: vec!["AtomGit".into()],
1892 default_provider: "AtomGit".into(),
1893 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
1894 all_models: vec![],
1895 }),
1896 status: StepResult::Err(huge),
1897 auth_expired: false,
1898 };
1899 let out = report.render();
1900 let line = out.lines().find(|l| l.contains("Status fetch failed")).unwrap();
1902 assert!(line.chars().count() < 250, "line still ~{} chars long", line.chars().count());
1904 assert!(line.contains('…'), "truncation marker present");
1905 }
1906
1907 #[test]
1908 fn format_duration_secs_human_readable() {
1909 assert_eq!(format_duration_secs(0), "0s");
1910 assert_eq!(format_duration_secs(45), "45s");
1911 assert_eq!(format_duration_secs(60), "1m");
1912 assert_eq!(format_duration_secs(90), "1m 30s");
1913 assert_eq!(format_duration_secs(3600), "1h");
1914 assert_eq!(format_duration_secs(3660), "1h 1m");
1915 assert_eq!(format_duration_secs(86400), "1d");
1916 assert_eq!(format_duration_secs(90060), "1d 1h");
1917 assert_eq!(format_duration_secs(-1), "—");
1918 }
1919
1920 #[test]
1921 fn truncate_inline_passes_short_strings_through() {
1922 assert_eq!(truncate_inline("short", 10), "short");
1923 assert_eq!(truncate_inline("exactly_ten", 11), "exactly_ten");
1924 }
1925
1926 #[test]
1927 fn truncate_inline_appends_ellipsis_when_long() {
1928 let r = truncate_inline("abcdefghijklmnop", 5);
1929 assert_eq!(r, "abcde…");
1930 }
1931
1932 #[test]
1933 fn truncate_inline_handles_unicode_safely() {
1934 let r = truncate_inline("一二三四五六七八", 5);
1936 assert_eq!(r, "一二三四五…");
1937 }
1938
1939 fn vl_model_entry(model: &str) -> super::super::types::ModelEntry {
1942 super::super::types::ModelEntry {
1943 id: 1,
1944 display_model_name: model.to_string(),
1945 plan_available: true,
1951 ..Default::default()
1956 }
1957 }
1958
1959 fn run_register(
1963 config: &mut Config,
1964 models: Vec<super::super::types::ModelEntry>,
1965 ) -> ModelsInfo {
1966 let stale: Vec<String> = config
1967 .providers
1968 .keys()
1969 .filter(|k| is_codingplan_provider_name(k))
1970 .cloned()
1971 .collect();
1972 for k in stale {
1973 config.providers.remove(&k);
1974 }
1975 let names: Vec<String> = models.iter().map(|m| m.display_model_name.clone()).collect();
1976 let provider_names = provider_names_for(&names);
1977 let default_provider = provider_names
1978 .first()
1979 .cloned()
1980 .unwrap_or_else(|| PROVIDER_PREFIX.to_string());
1981 for (pname, m) in provider_names.iter().zip(models.iter()) {
1982 config
1983 .providers
1984 .insert(pname.clone(), build_codingplan_provider(m));
1985 }
1986 config.default_provider = default_provider.clone();
1987
1988 let vl_idx = names
1989 .iter()
1990 .position(|n| crate::provider::model_name_suggests_vision(n));
1991 let new_vl_key = vl_idx.map(|i| provider_names[i].clone());
1992 let vision_preprocessor = {
1993 let current = config.vision_preprocessor_provider.clone();
1994 let user_supplied_non_atomgit = current
1995 .as_deref()
1996 .map(|k| !k.is_empty() && !is_codingplan_provider_name(k))
1997 .unwrap_or(false);
1998 if user_supplied_non_atomgit {
1999 VisionPreprocessorOutcome::UserSupplied(current.unwrap())
2000 } else {
2001 match new_vl_key {
2002 Some(k) => {
2003 config.vision_preprocessor_provider = Some(k.clone());
2004 VisionPreprocessorOutcome::AutoSet(k)
2005 }
2006 None => {
2007 if current.is_some() {
2008 config.vision_preprocessor_provider = None;
2009 VisionPreprocessorOutcome::Cleared
2010 } else {
2011 VisionPreprocessorOutcome::UnchangedNone
2012 }
2013 }
2014 }
2015 }
2016 };
2017
2018 ModelsInfo {
2019 display_names: names,
2020 provider_names,
2021 default_provider,
2022 vision_preprocessor,
2023 all_models: models,
2027 }
2028 }
2029
2030 #[test]
2031 fn vision_preprocessor_auto_set_when_none_and_list_has_vl() {
2032 let mut config = blank_config();
2033 let models = vec![
2034 vl_model_entry("moonshotai/Kimi-K2-Instruct"),
2035 vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2036 vl_model_entry("deepseek/deepseek-v4-flash"),
2037 ];
2038 let info = run_register(&mut config, models);
2039 let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
2040 assert_eq!(
2041 info.vision_preprocessor,
2042 VisionPreprocessorOutcome::AutoSet(expected.clone())
2043 );
2044 assert_eq!(config.vision_preprocessor_provider, Some(expected));
2045 }
2046
2047 #[test]
2048 fn vision_preprocessor_unchanged_none_when_list_has_no_vl() {
2049 let mut config = blank_config();
2050 let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
2051 let info = run_register(&mut config, models);
2052 assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::UnchangedNone);
2053 assert_eq!(config.vision_preprocessor_provider, None);
2054 }
2055
2056 #[test]
2057 fn vision_preprocessor_overwrites_stale_atomgit_value() {
2058 let mut config = blank_config();
2059 config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
2060 let models = vec![
2061 vl_model_entry("Kimi-K2-Instruct"),
2062 vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2063 ];
2064 let info = run_register(&mut config, models);
2065 let expected = "AtomGit-Qwen-Qwen3-VL-32B-Instruct".to_string();
2066 assert_eq!(
2067 info.vision_preprocessor,
2068 VisionPreprocessorOutcome::AutoSet(expected.clone())
2069 );
2070 assert_eq!(config.vision_preprocessor_provider, Some(expected));
2071 }
2072
2073 #[test]
2074 fn vision_preprocessor_cleared_when_stale_atomgit_and_list_has_no_vl() {
2075 let mut config = blank_config();
2076 config.vision_preprocessor_provider = Some("AtomGit-Qwen-Qwen2-VL-72B".into());
2077 let models = vec![vl_model_entry("moonshotai/Kimi-K2-Instruct")];
2078 let info = run_register(&mut config, models);
2079 assert_eq!(info.vision_preprocessor, VisionPreprocessorOutcome::Cleared);
2080 assert_eq!(config.vision_preprocessor_provider, None);
2081 }
2082
2083 #[test]
2084 fn vision_preprocessor_preserves_user_set_non_atomgit() {
2085 let mut config = blank_config();
2086 config.vision_preprocessor_provider = Some("Qwen3-VL-32B-Instruct".into());
2087 let models = vec![
2088 vl_model_entry("Kimi-K2-Instruct"),
2089 vl_model_entry("Qwen/Qwen3-VL-32B-Instruct"),
2090 ];
2091 let info = run_register(&mut config, models);
2092 assert_eq!(
2093 info.vision_preprocessor,
2094 VisionPreprocessorOutcome::UserSupplied("Qwen3-VL-32B-Instruct".into())
2095 );
2096 assert_eq!(
2097 config.vision_preprocessor_provider.as_deref(),
2098 Some("Qwen3-VL-32B-Instruct")
2099 );
2100 }
2101
2102 #[test]
2103 fn vision_preprocessor_recognises_pure_ocr_model_name() {
2104 let mut config = blank_config();
2105 let models = vec![
2106 vl_model_entry("Kimi-K2-Instruct"),
2107 vl_model_entry("PaddleOCR-2.0"),
2108 ];
2109 let info = run_register(&mut config, models);
2110 let expected = "AtomGit-PaddleOCR-2.0".to_string();
2111 assert_eq!(
2112 info.vision_preprocessor,
2113 VisionPreprocessorOutcome::AutoSet(expected.clone())
2114 );
2115 assert_eq!(config.vision_preprocessor_provider, Some(expected));
2116 }
2117
2118 #[test]
2119 fn render_includes_vision_preprocessor_auto_set_line() {
2120 let report = SetupReport {
2121 login: StepResult::Skipped("already logged in".into()),
2122 claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2123 claim_attempts: Vec::new(),
2124 models: StepResult::Ok(ModelsInfo {
2125 display_names: vec![
2126 "Kimi-K2-Instruct".into(),
2127 "Qwen/Qwen3-VL-32B-Instruct".into(),
2128 ],
2129 provider_names: vec![
2130 "AtomGit-Kimi-K2-Instruct".into(),
2131 "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2132 ],
2133 default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2134 vision_preprocessor: VisionPreprocessorOutcome::AutoSet(
2135 "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2136 ),
2137 all_models: vec![],
2138 }),
2139 status: StepResult::Skipped("status check skipped for this test".into()),
2140 auth_expired: false,
2141 };
2142 let out = report.render();
2143 assert!(
2144 out.contains("Vision preprocessor → AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
2145 "render must include the auto-detected line: {out}",
2146 );
2147 assert!(out.contains("(auto-detected)"));
2148 }
2149
2150 #[test]
2151 fn render_includes_vision_preprocessor_cleared_line_when_stale_dropped() {
2152 let report = SetupReport {
2153 login: StepResult::Skipped("already logged in".into()),
2154 claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2155 claim_attempts: Vec::new(),
2156 models: StepResult::Ok(ModelsInfo {
2157 display_names: vec!["Kimi-K2-Instruct".into()],
2158 provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
2159 default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2160 vision_preprocessor: VisionPreprocessorOutcome::Cleared,
2161 all_models: vec![],
2162 }),
2163 status: StepResult::Skipped("test skip".into()),
2164 auth_expired: false,
2165 };
2166 let out = report.render();
2167 assert!(out.contains("Vision preprocessor cleared"));
2168 }
2169
2170 #[test]
2171 fn render_includes_vision_preprocessor_user_supplied_line() {
2172 let report = SetupReport {
2173 login: StepResult::Skipped("already logged in".into()),
2174 claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2175 claim_attempts: Vec::new(),
2176 models: StepResult::Ok(ModelsInfo {
2177 display_names: vec![
2178 "Kimi-K2-Instruct".into(),
2179 "Qwen/Qwen3-VL-32B-Instruct".into(),
2180 ],
2181 provider_names: vec![
2182 "AtomGit-Kimi-K2-Instruct".into(),
2183 "AtomGit-Qwen-Qwen3-VL-32B-Instruct".into(),
2184 ],
2185 default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2186 vision_preprocessor: VisionPreprocessorOutcome::UserSupplied(
2187 "Qwen3-VL-32B-Instruct".into(),
2188 ),
2189 all_models: vec![],
2190 }),
2191 status: StepResult::Skipped("test skip".into()),
2192 auth_expired: false,
2193 };
2194 let out = report.render();
2195 assert!(out.contains("Vision preprocessor → Qwen3-VL-32B-Instruct"));
2196 assert!(out.contains("(user setting kept)"));
2197 }
2198
2199 #[test]
2200 fn render_omits_vision_preprocessor_line_when_unchanged_none() {
2201 let report = SetupReport {
2202 login: StepResult::Skipped("already logged in".into()),
2203 claim: StepResult::Ok(ClaimInfo { message: String::new(), duplicate: false, plan_type: PlanType::Max }),
2204 claim_attempts: Vec::new(),
2205 models: StepResult::Ok(ModelsInfo {
2206 display_names: vec!["Kimi-K2-Instruct".into()],
2207 provider_names: vec!["AtomGit-Kimi-K2-Instruct".into()],
2208 default_provider: "AtomGit-Kimi-K2-Instruct".into(),
2209 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
2210 all_models: vec![],
2211 }),
2212 status: StepResult::Skipped("test skip".into()),
2213 auth_expired: false,
2214 };
2215 let out = report.render();
2216 assert!(!out.contains("Vision preprocessor"));
2217 }
2218
2219 #[test]
2244 fn render_shows_locked_models_with_prefix_marker() {
2245 let avail = super::super::types::ModelEntry {
2246 id: 1,
2247 display_model_name: "lite/foo".into(),
2248 plan_available: true,
2249 ..Default::default()
2250 };
2251 let locked = super::super::types::ModelEntry {
2252 id: 2,
2253 display_model_name: "max/super-secret".into(),
2254 plan_available: false,
2255 ..Default::default()
2256 };
2257 let report = SetupReport {
2258 login: StepResult::Skipped("already logged in".into()),
2259 claim: StepResult::Ok(ClaimInfo {
2260 message: "claimed".into(),
2261 duplicate: false,
2262 plan_type: PlanType::Lite,
2263 }),
2264 claim_attempts: Vec::new(),
2265 models: StepResult::Ok(ModelsInfo {
2266 display_names: vec!["lite/foo".into()],
2267 provider_names: vec!["AtomGit".into()],
2268 default_provider: "AtomGit".into(),
2269 vision_preprocessor: VisionPreprocessorOutcome::UnchangedNone,
2270 all_models: vec![avail, locked],
2271 }),
2272 status: StepResult::Skipped("test skip".into()),
2273 auth_expired: false,
2274 };
2275 let out = report.render();
2276 assert!(out.contains("(CodingPlan Lite)"), "claim row must show tier:\n{out}");
2278 assert!(out.contains("AtomGit") && out.contains("lite/foo"));
2280 assert!(
2285 out.contains("\x1b[31m✗ max/super-secret"),
2286 "locked model must open with SGR 31 + ✗ prefix:\n{out}"
2287 );
2288 assert!(out.contains("(requires Pro plan or higher)\x1b[39m"));
2289 assert!(
2293 !out.contains("\x1b[9m"),
2294 "locked-model line must not emit SGR 9 strikethrough:\n{out}"
2295 );
2296 assert!(
2297 !out.contains('\u{0336}'),
2298 "locked-model line must not emit U+0336 combining strikethrough:\n{out}"
2299 );
2300 assert!(
2306 !out.contains("locked model"),
2307 "no separate locked-model section expected:\n{out}"
2308 );
2309 let added_idx = out.find("Added 1 provider").expect("Added header");
2310 let locked_idx = out.find("max/super-secret").expect("locked model line");
2311 let avail_idx = out.find("lite/foo").expect("available model line");
2312 assert!(
2313 locked_idx > added_idx,
2314 "locked model must render after the Added header:\n{out}"
2315 );
2316 assert!(
2317 locked_idx < avail_idx,
2318 "locked model must render BEFORE available providers (top-of-list upgrade prompt):\n{out}"
2319 );
2320 }
2321
2322}