feat: parse info from auth.json and show in /status (#1923)
- `/status` renders
```
signed in with chatgpt
login: example@example.com
plan: plus
```
- Setup for using this info in a few more places.
---------
Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -792,11 +792,14 @@ dependencies = [
|
|||||||
name = "codex-login"
|
name = "codex-login"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"pretty_assertions",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror 2.0.12",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -290,13 +290,10 @@ async fn chatgpt_auth_sends_correct_request() {
|
|||||||
let mut config = load_default_config_for_test(&codex_home);
|
let mut config = load_default_config_for_test(&codex_home);
|
||||||
config.model_provider = model_provider;
|
config.model_provider = model_provider;
|
||||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
let CodexSpawnOk { codex, .. } = Codex::spawn(
|
let CodexSpawnOk { codex, .. } =
|
||||||
config,
|
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
|
||||||
Some(auth_from_token("Access Token".to_string())),
|
.await
|
||||||
ctrl_c.clone(),
|
.unwrap();
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
.submit(Op::UserInput {
|
.submit(Op::UserInput {
|
||||||
@@ -541,13 +538,10 @@ async fn env_var_overrides_loaded_auth() {
|
|||||||
config.model_provider = provider;
|
config.model_provider = provider;
|
||||||
|
|
||||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
let CodexSpawnOk { codex, .. } = Codex::spawn(
|
let CodexSpawnOk { codex, .. } =
|
||||||
config,
|
Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
|
||||||
Some(auth_from_token("Default Access Token".to_string())),
|
.await
|
||||||
ctrl_c.clone(),
|
.unwrap();
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
.submit(Op::UserInput {
|
.submit(Op::UserInput {
|
||||||
@@ -561,7 +555,7 @@ async fn env_var_overrides_loaded_auth() {
|
|||||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auth_from_token(id_token: String) -> CodexAuth {
|
fn create_dummy_codex_auth() -> CodexAuth {
|
||||||
CodexAuth::new(
|
CodexAuth::new(
|
||||||
None,
|
None,
|
||||||
AuthMode::ChatGPT,
|
AuthMode::ChatGPT,
|
||||||
@@ -569,7 +563,7 @@ fn auth_from_token(id_token: String) -> CodexAuth {
|
|||||||
Some(AuthDotJson {
|
Some(AuthDotJson {
|
||||||
openai_api_key: None,
|
openai_api_key: None,
|
||||||
tokens: Some(TokenData {
|
tokens: Some(TokenData {
|
||||||
id_token,
|
id_token: Default::default(),
|
||||||
access_token: "Access Token".to_string(),
|
access_token: "Access Token".to_string(),
|
||||||
refresh_token: "test".to_string(),
|
refresh_token: "test".to_string(),
|
||||||
account_id: Some("account_id".to_string()),
|
account_id: Some("account_id".to_string()),
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ version = { workspace = true }
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
thiserror = "2.0.12"
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
"io-std",
|
"io-std",
|
||||||
"macros",
|
"macros",
|
||||||
@@ -20,4 +22,5 @@ tokio = { version = "1", features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "1.4.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ use std::sync::Mutex;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
pub use crate::token_data::TokenData;
|
||||||
|
use crate::token_data::parse_id_token;
|
||||||
|
|
||||||
|
mod token_data;
|
||||||
|
|
||||||
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
|
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
|
||||||
|
|
||||||
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||||
@@ -182,7 +187,7 @@ pub fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result<Op
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_auth_file(codex_home: &Path) -> PathBuf {
|
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||||
codex_home.join("auth.json")
|
codex_home.join("auth.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +337,7 @@ async fn update_tokens(
|
|||||||
let mut auth_dot_json = try_read_auth_json(auth_file)?;
|
let mut auth_dot_json = try_read_auth_json(auth_file)?;
|
||||||
|
|
||||||
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
|
let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
|
||||||
tokens.id_token = id_token.to_string();
|
tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?;
|
||||||
if let Some(access_token) = access_token {
|
if let Some(access_token) = access_token {
|
||||||
tokens.access_token = access_token.to_string();
|
tokens.access_token = access_token.to_string();
|
||||||
}
|
}
|
||||||
@@ -403,22 +408,12 @@ pub struct AuthDotJson {
|
|||||||
pub last_refresh: Option<DateTime<Utc>>,
|
pub last_refresh: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
|
|
||||||
pub struct TokenData {
|
|
||||||
/// This is a JWT.
|
|
||||||
pub id_token: String,
|
|
||||||
|
|
||||||
/// This is a JWT.
|
|
||||||
pub access_token: String,
|
|
||||||
|
|
||||||
pub refresh_token: String,
|
|
||||||
|
|
||||||
pub account_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::token_data::IdTokenInfo;
|
||||||
|
use base64::Engine;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -446,10 +441,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[expect(clippy::unwrap_used)]
|
#[expect(clippy::expect_used, clippy::unwrap_used)]
|
||||||
async fn loads_token_data_from_auth_json() {
|
async fn loads_token_data_from_auth_json() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let auth_file = dir.path().join("auth.json");
|
let auth_file = dir.path().join("auth.json");
|
||||||
|
// Create a minimal valid JWT for the id_token field.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Header {
|
||||||
|
alg: &'static str,
|
||||||
|
typ: &'static str,
|
||||||
|
}
|
||||||
|
let header = Header {
|
||||||
|
alg: "none",
|
||||||
|
typ: "JWT",
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"email": "user@example.com",
|
||||||
|
"email_verified": true,
|
||||||
|
"https://api.openai.com/auth": {
|
||||||
|
"chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
|
||||||
|
"chatgpt_plan_type": "pro",
|
||||||
|
"chatgpt_user_id": "user-12345",
|
||||||
|
"user_id": "user-12345",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
||||||
|
let header_b64 = b64(&serde_json::to_vec(&header).unwrap());
|
||||||
|
let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap());
|
||||||
|
let signature_b64 = b64(b"sig");
|
||||||
|
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
auth_file,
|
auth_file,
|
||||||
format!(
|
format!(
|
||||||
@@ -457,30 +477,68 @@ mod tests {
|
|||||||
{{
|
{{
|
||||||
"OPENAI_API_KEY": null,
|
"OPENAI_API_KEY": null,
|
||||||
"tokens": {{
|
"tokens": {{
|
||||||
"id_token": "test-id-token",
|
"id_token": "{fake_jwt}",
|
||||||
"access_token": "test-access-token",
|
"access_token": "test-access-token",
|
||||||
"refresh_token": "test-refresh-token"
|
"refresh_token": "test-refresh-token"
|
||||||
}},
|
}},
|
||||||
"last_refresh": "{}"
|
"last_refresh": "2025-08-06T20:41:36.232376Z"
|
||||||
}}
|
}}
|
||||||
"#,
|
"#,
|
||||||
Utc::now().to_rfc3339()
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let auth = load_auth(dir.path(), false).unwrap().unwrap();
|
let CodexAuth {
|
||||||
assert_eq!(auth.mode, AuthMode::ChatGPT);
|
api_key,
|
||||||
assert_eq!(auth.api_key, None);
|
mode,
|
||||||
|
auth_dot_json,
|
||||||
|
auth_file,
|
||||||
|
} = load_auth(dir.path(), false).unwrap().unwrap();
|
||||||
|
assert_eq!(None, api_key);
|
||||||
|
assert_eq!(AuthMode::ChatGPT, mode);
|
||||||
|
assert_eq!(dir.path().join("auth.json"), auth_file);
|
||||||
|
|
||||||
|
let guard = auth_dot_json.lock().unwrap();
|
||||||
|
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
auth.get_token_data().await.unwrap(),
|
&AuthDotJson {
|
||||||
TokenData {
|
openai_api_key: None,
|
||||||
id_token: "test-id-token".to_string(),
|
tokens: Some(TokenData {
|
||||||
access_token: "test-access-token".to_string(),
|
id_token: IdTokenInfo {
|
||||||
refresh_token: "test-refresh-token".to_string(),
|
email: Some("user@example.com".to_string()),
|
||||||
account_id: None,
|
chatgpt_plan_type: Some("pro".to_string()),
|
||||||
}
|
},
|
||||||
);
|
access_token: "test-access-token".to_string(),
|
||||||
|
refresh_token: "test-refresh-token".to_string(),
|
||||||
|
account_id: None,
|
||||||
|
}),
|
||||||
|
last_refresh: Some(
|
||||||
|
DateTime::parse_from_rfc3339("2025-08-06T20:41:36.232376Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auth_dot_json
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[expect(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
fn id_token_info_handles_missing_fields() {
|
||||||
|
// Payload without email or plan should yield None values.
|
||||||
|
let header = serde_json::json!({"alg": "none", "typ": "JWT"});
|
||||||
|
let payload = serde_json::json!({"sub": "123"});
|
||||||
|
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.encode(serde_json::to_vec(&header).unwrap());
|
||||||
|
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.encode(serde_json::to_vec(&payload).unwrap());
|
||||||
|
let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
|
||||||
|
let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||||
|
|
||||||
|
let info = parse_id_token(&jwt).expect("should parse");
|
||||||
|
assert!(info.email.is_none());
|
||||||
|
assert!(info.chatgpt_plan_type.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
117
codex-rs/login/src/token_data.rs
Normal file
117
codex-rs/login/src/token_data.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
|
||||||
|
pub struct TokenData {
|
||||||
|
/// Flat info parsed from the JWT in auth.json.
|
||||||
|
#[serde(deserialize_with = "deserialize_id_token")]
|
||||||
|
pub id_token: IdTokenInfo,
|
||||||
|
|
||||||
|
/// This is a JWT.
|
||||||
|
pub access_token: String,
|
||||||
|
|
||||||
|
pub refresh_token: String,
|
||||||
|
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat subset of useful claims in id_token from auth.json.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
|
||||||
|
pub struct IdTokenInfo {
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// The ChatGPT subscription plan type
|
||||||
|
/// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
|
||||||
|
/// (Note: ae has not verified that those are the exact values.)
|
||||||
|
pub chatgpt_plan_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct IdClaims {
|
||||||
|
#[serde(default)]
|
||||||
|
email: Option<String>,
|
||||||
|
#[serde(rename = "https://api.openai.com/auth", default)]
|
||||||
|
auth: Option<AuthClaims>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuthClaims {
|
||||||
|
#[serde(default)]
|
||||||
|
chatgpt_plan_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum IdTokenInfoError {
|
||||||
|
#[error("invalid ID token format")]
|
||||||
|
InvalidFormat,
|
||||||
|
#[error(transparent)]
|
||||||
|
Base64(#[from] base64::DecodeError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||||
|
// JWT format: header.payload.signature
|
||||||
|
let mut parts = id_token.split('.');
|
||||||
|
let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
|
||||||
|
_ => return Err(IdTokenInfoError::InvalidFormat),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
|
||||||
|
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
|
||||||
|
|
||||||
|
Ok(IdTokenInfo {
|
||||||
|
email: claims.email,
|
||||||
|
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
parse_id_token(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[expect(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
fn id_token_info_parses_email_and_plan() {
|
||||||
|
// Build a fake JWT with a URL-safe base64 payload containing email and plan.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Header {
|
||||||
|
alg: &'static str,
|
||||||
|
typ: &'static str,
|
||||||
|
}
|
||||||
|
let header = Header {
|
||||||
|
alg: "none",
|
||||||
|
typ: "JWT",
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"email": "user@example.com",
|
||||||
|
"https://api.openai.com/auth": {
|
||||||
|
"chatgpt_plan_type": "pro"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fn b64url_no_pad(bytes: &[u8]) -> String {
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap());
|
||||||
|
let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap());
|
||||||
|
let signature_b64 = b64url_no_pad(b"sig");
|
||||||
|
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||||
|
|
||||||
|
let info = parse_id_token(&fake_jwt).expect("should parse");
|
||||||
|
assert_eq!(info.email.as_deref(), Some("user@example.com"));
|
||||||
|
assert_eq!(info.chatgpt_plan_type.as_deref(), Some("pro"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ use codex_core::protocol::FileChange;
|
|||||||
use codex_core::protocol::McpInvocation;
|
use codex_core::protocol::McpInvocation;
|
||||||
use codex_core::protocol::SessionConfiguredEvent;
|
use codex_core::protocol::SessionConfiguredEvent;
|
||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
|
use codex_login::get_auth_file;
|
||||||
|
use codex_login::try_read_auth_json;
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use mcp_types::EmbeddedResourceResource;
|
use mcp_types::EmbeddedResourceResource;
|
||||||
@@ -469,8 +471,38 @@ impl HistoryCell {
|
|||||||
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token usage
|
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
let auth_file = get_auth_file(&config.codex_home);
|
||||||
|
if let Ok(auth) = try_read_auth_json(&auth_file) {
|
||||||
|
if auth.tokens.as_ref().is_some() {
|
||||||
|
lines.push(Line::from("signed in with chatgpt".bold()));
|
||||||
|
|
||||||
|
if let Some(tokens) = auth.tokens.as_ref() {
|
||||||
|
let info = tokens.id_token.clone();
|
||||||
|
if let Some(email) = info.email {
|
||||||
|
lines.push(Line::from(vec![" login: ".bold(), email.into()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
match auth.openai_api_key.as_deref() {
|
||||||
|
Some(key) if !key.is_empty() => {
|
||||||
|
lines.push(Line::from(" using api key"));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let plan_text = info
|
||||||
|
.chatgpt_plan_type
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
lines.push(Line::from(vec![" plan: ".bold(), plan_text.into()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token usage
|
||||||
lines.push(Line::from("token usage".bold()));
|
lines.push(Line::from("token usage".bold()));
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
" input: ".bold(),
|
" input: ".bold(),
|
||||||
|
|||||||
Reference in New Issue
Block a user