ai_usagebar/modelstudio/
creds.rs1use std::path::Path;
17
18use crate::error::{AppError, Result};
19
20use super::types::{ConsoleRegion, ConsoleSite};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Credentials {
25 pub access_token: String,
27 pub site: ConsoleSite,
28 pub region: ConsoleRegion,
29 pub fingerprint: String,
33}
34
35#[derive(serde::Deserialize)]
36struct ConfigFile {
37 #[serde(default, alias = "accessToken")]
38 access_token: Option<String>,
39 #[serde(default)]
40 console_site: Option<String>,
41 #[serde(default)]
42 console_region: Option<String>,
43}
44
45pub fn read_from(path: &Path) -> Result<Credentials> {
49 let raw = std::fs::read(path).map_err(|e| {
50 if e.kind() == std::io::ErrorKind::NotFound {
51 AppError::Credentials(format!(
52 "Model Studio: no bl CLI config at {}; install the official `bl` CLI and \
53 run `bl auth login --console` to re-auth",
54 crate::display::sanitize_untrusted_path(path)
55 ))
56 } else {
57 AppError::io_at(path, e)
58 }
59 })?;
60 parse(&raw)
61}
62
63fn parse(raw: &[u8]) -> Result<Credentials> {
64 let file: ConfigFile = serde_json::from_slice(raw).map_err(|_| malformed())?;
65 let access_token = file
66 .access_token
67 .filter(|token| !token.trim().is_empty())
68 .ok_or_else(malformed)?;
69 Ok(Credentials {
70 fingerprint: crate::cache::fingerprint_of(&access_token),
71 site: ConsoleSite::parse(file.console_site.as_deref().unwrap_or("domestic")),
72 region: ConsoleRegion::parse(file.console_region.as_deref().unwrap_or("cn-beijing")),
73 access_token,
74 })
75}
76
77fn malformed() -> AppError {
78 AppError::Credentials(
79 "Model Studio: the bl CLI's config.json carries no console token; \
80 run `bl auth login --console` to re-auth"
81 .into(),
82 )
83}
84
85pub fn config_present_at(path: &Path) -> bool {
88 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use tempfile::TempDir;
95
96 fn write_config(dir: &TempDir, contents: &str) -> std::path::PathBuf {
97 let path = dir.path().join("config.json");
98 std::fs::write(&path, contents).unwrap();
99 path
100 }
101
102 #[test]
103 fn a_snake_case_config_yields_the_console_session() {
104 let td = TempDir::new().unwrap();
105 let path = write_config(
106 &td,
107 r#"{"access_token":"tok-1","console_site":"international",
108 "console_region":"ap-southeast-1"}"#,
109 );
110 let creds = read_from(&path).unwrap();
111 assert_eq!(creds.access_token, "tok-1");
112 assert_eq!(creds.site, ConsoleSite::International);
113 assert_eq!(creds.region, ConsoleRegion::ApSoutheast1);
114 assert_eq!(creds.fingerprint, crate::cache::fingerprint_of("tok-1"));
115 assert_eq!(creds.fingerprint.len(), 16);
116 assert!(creds.fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
117 }
118
119 #[test]
121 fn a_camel_case_access_token_alias_parses() {
122 let td = TempDir::new().unwrap();
123 let path = write_config(&td, r#"{"accessToken":"tok-2"}"#);
124 let creds = read_from(&path).unwrap();
125 assert_eq!(creds.access_token, "tok-2");
126 }
127
128 #[test]
129 fn site_and_region_default_to_the_cli_rows_and_unknown_values_fall_back() {
130 let td = TempDir::new().unwrap();
131 let path = write_config(&td, r#"{"access_token":"tok-3"}"#);
132 let creds = read_from(&path).unwrap();
133 assert_eq!(creds.site, ConsoleSite::Domestic);
134 assert_eq!(creds.region, ConsoleRegion::CnBeijing);
135
136 let path = write_config(
137 &td,
138 r#"{"access_token":"tok-3","console_site":"mars","console_region":"eu-west-1"}"#,
139 );
140 let creds = read_from(&path).unwrap();
141 assert_eq!(creds.site, ConsoleSite::Domestic);
142 assert_eq!(creds.region, ConsoleRegion::CnBeijing);
143 }
144
145 #[test]
147 fn a_missing_token_names_the_fix() {
148 let td = TempDir::new().unwrap();
149 for (name, contents) in [
150 ("no-token", r#"{"console_site":"domestic"}"#),
151 ("empty-token", r#"{"access_token":" "}"#),
152 ("not-json", "nope"),
153 ("wrong-shape", r#""just a string""#),
154 ] {
155 let path = write_config(&td, contents);
156 let err = read_from(&path).unwrap_err();
157 assert!(matches!(err, AppError::Credentials(_)), "{name}: {err:?}");
158 assert!(
159 err.to_string().contains("bl auth login --console"),
160 "{name}: {err}"
161 );
162 assert!(!err.to_string().contains("console_site"), "{name}: {err}");
164 }
165 }
166
167 #[test]
168 fn a_missing_file_is_a_credentials_error_naming_the_path_checked() {
169 let td = TempDir::new().unwrap();
170 let missing = td.path().join("absent").join("config.json");
171 let err = read_from(&missing).unwrap_err();
172 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
173 let message = err.to_string();
174 assert!(message.contains("bl auth login --console"), "{message}");
175 assert!(message.contains("config.json"), "{message}");
176 }
177
178 #[test]
179 fn the_probe_wants_a_non_empty_file() {
180 let td = TempDir::new().unwrap();
181 let path = td.path().join("config.json");
182 assert!(!config_present_at(&path));
183 std::fs::write(&path, "").unwrap();
184 assert!(!config_present_at(&path));
185 std::fs::write(&path, "{}").unwrap();
186 assert!(config_present_at(&path));
187 assert!(!config_present_at(td.path()));
188 }
189}