use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use clap::{Subcommand, ValueEnum};
use bitrouter_ai::providers::hosted::credentials::{CredentialKind, StoredCredential};
use crate::cloud::account::manager::CredentialManager;
use super::api::ApiArgs;
use super::auth::{LoginInputs, login, logout};
use super::management::error::Error as SdkError;
use super::management::types::{BudgetWindow as SdkBudgetWindow, PolicyKind as SdkPolicyKind};
use super::management::{
ManagementClient, billing, budgets, byok, keys, namespaces, policies, presets, usage,
};
#[derive(Subcommand)]
pub enum CloudAction {
Whoami,
Api(ApiArgs),
Login {
#[arg(long = "oauth-as", value_name = "URL")]
authorization_server: Option<String>,
#[arg(long = "client-id", value_name = "ID")]
client_id: Option<String>,
#[arg(long, value_name = "SCOPE")]
scope: Option<String>,
#[arg(
long,
value_name = "BRK_API_KEY",
conflicts_with_all = ["client_id", "scope"]
)]
api_key: Option<String>,
},
Logout {
#[arg(long = "oauth-as", value_name = "URL")]
authorization_server: Option<String>,
#[arg(long = "client-id", value_name = "ID")]
client_id: Option<String>,
},
Namespace {
#[command(subcommand)]
action: NamespaceAction,
},
Keys {
#[command(subcommand)]
action: KeysAction,
},
Usage(UsageArgs),
Requests(RequestsArgs),
Billing {
#[command(subcommand)]
action: BillingAction,
},
Policy {
#[command(subcommand)]
action: PolicyAction,
},
Budget {
#[command(subcommand)]
action: BudgetAction,
},
Preset {
#[command(subcommand)]
action: PresetAction,
},
Byok {
#[command(subcommand)]
action: ByokAction,
},
}
#[derive(Debug, Subcommand)]
pub enum NamespaceAction {
List(JsonFlag),
Current(JsonFlag),
}
#[derive(Debug, Subcommand)]
pub enum KeysAction {
List(JsonFlag),
Mint(MintKeyArgs),
Revoke(RevokeKeyArgs),
}
#[derive(Debug, clap::Args)]
pub struct MintKeyArgs {
#[arg(long)]
pub name: String,
#[arg(long = "scope", value_name = "SCOPE")]
pub scopes: Vec<String>,
#[arg(long)]
pub expires_at: Option<DateTime<Utc>>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct RevokeKeyArgs {
pub id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct UsageArgs {
#[arg(long)]
pub from: Option<DateTime<Utc>>,
#[arg(long)]
pub to: Option<DateTime<Utc>>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct RequestsArgs {
#[arg(long)]
pub limit: Option<u64>,
#[arg(long)]
pub offset: Option<u64>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Subcommand)]
pub enum BillingAction {
Balance(JsonFlag),
Checkout(CheckoutArgs),
}
#[derive(Debug, clap::Args)]
pub struct CheckoutArgs {
#[arg(long)]
pub amount_cents: i64,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Subcommand)]
pub enum PolicyAction {
List(PolicyListArgs),
Get(IdArg),
Create(PolicyCreateArgs),
Update(PolicyUpdateArgs),
Delete(IdArg),
Bind(PolicyBindArgs),
Unbind(PolicyUnbindArgs),
Disable(IdArg),
Enable(IdArg),
Bindings(IdArg),
Effective(EffectiveArgs),
#[command(name = "for-principal")]
ForPrincipal(ForPrincipalArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum PolicyKindArg {
Budget,
RateLimit,
Guardrail,
Preset,
}
impl From<PolicyKindArg> for SdkPolicyKind {
fn from(value: PolicyKindArg) -> Self {
match value {
PolicyKindArg::Budget => SdkPolicyKind::Budget,
PolicyKindArg::RateLimit => SdkPolicyKind::RateLimit,
PolicyKindArg::Guardrail => SdkPolicyKind::Guardrail,
PolicyKindArg::Preset => SdkPolicyKind::Preset,
}
}
}
#[derive(Debug, clap::Args)]
pub struct PolicyListArgs {
#[arg(long)]
pub kind: Option<PolicyKindArg>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct PolicyCreateArgs {
#[arg(long)]
pub name: String,
#[arg(long)]
pub kind: PolicyKindArg,
#[arg(long)]
pub spec: PathBuf,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct PolicyUpdateArgs {
pub id: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub spec: Option<PathBuf>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct PolicyBindArgs {
pub id: String,
#[arg(long)]
pub principal_type: String,
#[arg(long)]
pub principal_id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct PolicyUnbindArgs {
pub id: String,
pub binding_id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct EffectiveArgs {
#[arg(long)]
pub principal_type: String,
#[arg(long)]
pub principal_id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct ForPrincipalArgs {
pub principal_type: String,
pub principal_id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Subcommand)]
pub enum BudgetAction {
List(JsonFlag),
Get(IdArg),
Create(BudgetCreateArgs),
Update(BudgetUpdateArgs),
Delete(IdArg),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum BudgetWindowArg {
Day,
Month,
Total,
}
impl From<BudgetWindowArg> for SdkBudgetWindow {
fn from(value: BudgetWindowArg) -> Self {
match value {
BudgetWindowArg::Day => SdkBudgetWindow::Day,
BudgetWindowArg::Month => SdkBudgetWindow::Month,
BudgetWindowArg::Total => SdkBudgetWindow::Total,
}
}
}
#[derive(Debug, clap::Args)]
pub struct BudgetCreateArgs {
#[arg(long)]
pub name: String,
#[arg(long)]
pub window: BudgetWindowArg,
#[arg(long)]
pub limit_micro_usd: i64,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct BudgetUpdateArgs {
pub id: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub window: Option<BudgetWindowArg>,
#[arg(long)]
pub limit_micro_usd: Option<i64>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Subcommand)]
pub enum PresetAction {
List(JsonFlag),
Get(IdArg),
Create(PresetCreateArgs),
Update(PresetUpdateArgs),
Delete(IdArg),
}
#[derive(Debug, clap::Args)]
pub struct PresetCreateArgs {
#[arg(long)]
pub name: String,
#[arg(long)]
pub guardrail: Option<PathBuf>,
#[arg(long)]
pub budget: Option<PathBuf>,
#[arg(long)]
pub rate_limit: Option<PathBuf>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct PresetUpdateArgs {
pub id: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub guardrail: Option<PathBuf>,
#[arg(long)]
pub budget: Option<PathBuf>,
#[arg(long)]
pub rate_limit: Option<PathBuf>,
#[arg(long)]
pub clear_guardrail: bool,
#[arg(long)]
pub clear_budget: bool,
#[arg(long)]
pub clear_rate_limit: bool,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Subcommand)]
pub enum ByokAction {
List(JsonFlag),
Set(ByokSetArgs),
Delete(ByokDeleteArgs),
}
#[derive(Debug, clap::Args)]
pub struct ByokSetArgs {
#[arg(long)]
pub provider: String,
#[arg(long)]
pub ciphertext_b64: String,
#[arg(long)]
pub kek_id: String,
#[arg(long)]
pub key_prefix: String,
#[arg(long)]
pub api_base: Option<String>,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct ByokDeleteArgs {
pub provider: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, clap::Args)]
pub struct IdArg {
pub id: String,
#[command(flatten)]
pub json: JsonFlag,
}
#[derive(Debug, Default, Clone, Copy, clap::Args)]
pub struct JsonFlag {
#[arg(long)]
pub json: bool,
}
pub async fn run(action: CloudAction, format: crate::output::Format) -> Result<()> {
let _ = CLOUD_FORMAT.set(format);
let manager = super::default_manager()?;
let result = run_inner(action, Arc::clone(&manager)).await;
match result {
Ok(()) => Ok(()),
Err(err) => {
print_error_hint(&err, &manager).await;
Err(err.into())
}
}
}
async fn run_inner(
action: CloudAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
match action {
CloudAction::Whoami => whoami(manager).await,
CloudAction::Api(args) => super::api::run(args, manager).await.map_err(SdkError::Auth),
CloudAction::Login {
authorization_server,
client_id,
scope,
api_key,
} => {
let credential = login(
Arc::clone(&manager),
LoginInputs {
authorization_server,
client_id,
scope,
api_key,
},
)
.await
.map_err(SdkError::Auth)?;
let client = client(Arc::clone(&manager)).await?;
let current = manager
.current()
.await
.map_err(|error| SdkError::Auth(anyhow::anyhow!(error)))?;
let body = serde_json::json!({
"signed_in": true,
"authentication": credential_kind_name(credential.kind()),
"namespace": client.namespace_id(),
"subject": current.as_ref().and_then(StoredCredential::subject),
"scope": current.as_ref().and_then(StoredCredential::scope),
"credentials_path": manager.path().display().to_string(),
});
emit(false, &body, |_| "signed in".to_string())
}
CloudAction::Logout {
authorization_server,
client_id,
} => {
logout(
manager,
LoginInputs {
authorization_server,
client_id,
scope: None,
api_key: None,
},
)
.await
.map_err(SdkError::Auth)?;
emit(false, &serde_json::json!({ "signed_out": true }), |_| {
"signed out".to_string()
})
}
CloudAction::Namespace { action } => run_namespace(action, manager).await,
CloudAction::Keys { action } => run_keys(action, manager).await,
CloudAction::Usage(args) => run_usage(args, manager).await,
CloudAction::Requests(args) => run_requests(args, manager).await,
CloudAction::Billing { action } => run_billing(action, manager).await,
CloudAction::Policy { action } => run_policy(action, manager).await,
CloudAction::Budget { action } => run_budget(action, manager).await,
CloudAction::Preset { action } => run_preset(action, manager).await,
CloudAction::Byok { action } => run_byok(action, manager).await,
}
}
async fn client(
manager: Arc<CredentialManager>,
) -> std::result::Result<ManagementClient, SdkError> {
ManagementClient::from_manager(manager).await
}
async fn whoami(manager: Arc<CredentialManager>) -> std::result::Result<(), SdkError> {
let client = client(Arc::clone(&manager)).await?;
let current = manager
.current()
.await
.map_err(|error| SdkError::Auth(anyhow::anyhow!(error)))?;
let scope = current
.as_ref()
.and_then(|credential| credential.scope().map(ToOwned::to_owned));
let subject = current
.as_ref()
.and_then(|credential| credential.subject().map(ToOwned::to_owned));
let signed_in = current.is_some();
let authentication = current
.as_ref()
.map(|credential| credential_kind_name(credential.kind()));
let body = serde_json::json!({
"signed_in": signed_in,
"authentication": authentication,
"base_url": client.base_url(),
"namespace": client.namespace_id(),
"scope": scope.clone(),
"subject": subject.clone(),
"credentials_path": manager.path().display().to_string(),
});
emit(false, &body, |_| {
let mut out = format!(
"cloud base URL: {}\nnamespace: {}",
client.base_url(),
client.namespace_id().unwrap_or("(none)")
);
if signed_in {
if let Some(authentication) = authentication {
out.push_str(&format!("\nauthentication: {authentication}"));
}
if let Some(s) = &scope {
out.push_str(&format!("\nscope: {s}"));
}
if let Some(sub) = &subject {
out.push_str(&format!("\nsubject: {sub}"));
}
out.push_str(&format!("\ncredentials: {}", manager.path().display()));
} else {
out.push_str(&format!(
"\n(not signed in — run `{} cloud login`)",
bitrouter_sdk::invocation::name()
));
}
out
})
}
fn credential_kind_name(kind: CredentialKind) -> &'static str {
match kind {
CredentialKind::Oauth => "oauth",
CredentialKind::ApiKey => "api_key",
}
}
async fn run_namespace(
action: NamespaceAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
NamespaceAction::List(flag) => {
let resp = client.list_namespaces().await?;
let active = client.namespace_id().map(str::to_owned);
emit(flag.json, &resp, |r| {
format_namespace_list(r, active.as_deref())
})
}
NamespaceAction::Current(flag) => {
let nsid = client.namespace_id();
let body = serde_json::json!({ "namespace_id": nsid });
emit(flag.json, &body, |_| match nsid {
Some(id) => id.to_owned(),
None => format!(
"(no namespace — run `{} cloud login`)",
bitrouter_sdk::invocation::name()
),
})
}
}
}
fn format_namespace_list(resp: &namespaces::NamespaceListResponse, active: Option<&str>) -> String {
if resp.data.is_empty() {
return "no namespaces".to_owned();
}
let mut out = String::new();
for ns in &resp.data {
let marker = if Some(ns.id.as_str()) == active {
" (active)"
} else {
""
};
out.push_str(&format!("{:<28} {}{}\n", ns.id, ns.name, marker));
}
out
}
async fn run_keys(
action: KeysAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
KeysAction::List(flag) => emit(flag.json, &client.list_keys().await?, format_keys_list),
KeysAction::Mint(args) => {
let body = keys::MintApiKeyRequest {
display_name: args.name,
scopes: split_scope_args(&args.scopes),
expires_at: args.expires_at,
};
let resp = client.mint_key(&body).await?;
emit(args.json.json, &resp, format_mint_key)
}
KeysAction::Revoke(args) => {
let resp = client.revoke_key(&args.id).await?;
emit(args.json.json, &resp, |r| format!("revoked: {}", r.revoked))
}
}
}
fn format_keys_list(resp: &keys::ApiKeyListResponse) -> String {
if resp.data.is_empty() {
return "no keys".to_owned();
}
let mut out = String::new();
for k in &resp.data {
out.push_str(&format!(
"{:<24} {:<24} scopes=[{}] prefix={}\n",
k.id,
k.display_name,
k.scopes.join(", "),
k.key_prefix,
));
}
out
}
fn format_mint_key(resp: &keys::MintApiKeyResponse) -> String {
format!(
"id: {id}\nname: {name}\nprefix: {prefix}\nscopes: [{scopes}]\ntoken: {token} (shown once — save it now)\n",
id = resp.id,
name = resp.display_name,
prefix = resp.key_prefix,
scopes = resp.scopes.join(", "),
token = resp.token,
)
}
async fn run_usage(
args: UsageArgs,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
let resp = client
.usage_aggregate(&usage::UsageQuery {
from: args.from,
to: args.to,
})
.await?;
emit(args.json.json, &resp, |r| {
format!(
"window: {from} → {to}\nspend (micro-USD): {spend}\nprompt tokens: {pt}\ncompletion tokens: {ct}\nrequests: {rc}\n",
from = r.from.to_rfc3339(),
to = r.to.to_rfc3339(),
spend = r.spend_micro_usd,
pt = r.prompt_tokens,
ct = r.completion_tokens,
rc = r.request_count,
)
})
}
async fn run_requests(
args: RequestsArgs,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
let resp = client
.list_requests(&usage::RequestsQuery {
limit: args.limit,
offset: args.offset,
})
.await?;
emit(args.json.json, &resp, format_requests)
}
fn format_requests(resp: &usage::RequestsResponse) -> String {
if resp.data.is_empty() {
return "no requests".to_owned();
}
let mut out = String::new();
for r in &resp.data {
out.push_str(&format!(
"{} {:<10} {:<24} {:>10} µUSD prompt={:<6} completion={:<6}\n",
r.created_at.to_rfc3339(),
r.status,
r.model_id.clone().unwrap_or_else(|| "—".into()),
r.final_charge_micro_usd
.map(|v| v.to_string())
.unwrap_or_else(|| "—".into()),
r.prompt_tokens,
r.completion_tokens,
));
}
out.push_str(&format!("\nlimit={} offset={}\n", resp.limit, resp.offset));
out
}
async fn run_billing(
action: BillingAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
BillingAction::Balance(flag) => {
let resp = client.billing_balance().await?;
emit(flag.json, &resp, |r| {
format!(
"balance: {} {} (micro-USD)\npending: {} {}\navailable: {} {}\n",
r.balance_micro_usd,
r.currency,
r.pending_debits_micro_usd,
r.currency,
r.available_micro_usd,
r.currency,
)
})
}
BillingAction::Checkout(args) => {
let resp = client
.create_checkout_session(&billing::CheckoutSessionRequest {
amount_cents: args.amount_cents,
})
.await?;
emit(args.json.json, &resp, |r| {
format!("session id: {}\ncheckout url: {}\n", r.id, r.url)
})
}
}
}
async fn run_policy(
action: PolicyAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
PolicyAction::List(args) => {
let resp = client
.list_policies(&policies::ListPoliciesQuery {
kind: args.kind.map(Into::into),
})
.await?;
emit(args.json.json, &resp, format_policy_list)
}
PolicyAction::Get(args) => {
let resp = client.get_policy(&args.id).await?;
emit(args.json.json, &resp, format_policy_one)
}
PolicyAction::Create(args) => {
let spec = read_json_input(&args.spec)?;
let resp = client
.create_policy(&policies::CreatePolicyRequest {
name: args.name,
kind: args.kind.into(),
spec,
})
.await?;
emit(args.json.json, &resp, format_policy_one)
}
PolicyAction::Update(args) => {
let spec = match args.spec.as_ref() {
Some(p) => Some(read_json_input(p)?),
None => None,
};
let resp = client
.update_policy(
&args.id,
&policies::UpdatePolicyRequest {
name: args.name,
spec,
},
)
.await?;
emit(args.json.json, &resp, format_policy_one)
}
PolicyAction::Delete(args) => {
let resp = client.delete_policy(&args.id).await?;
emit(args.json.json, &resp, |r| {
format!("deleted: {}\n", r.deleted)
})
}
PolicyAction::Bind(args) => {
let resp = client
.bind_policy(
&args.id,
&policies::BindPolicyRequest {
principal_type: args.principal_type,
principal_id: args.principal_id,
},
)
.await?;
emit(args.json.json, &resp, |r| {
format!("binding id: {}\n", r.binding_id)
})
}
PolicyAction::Unbind(args) => {
let resp = client.unbind_policy(&args.id, &args.binding_id).await?;
emit(args.json.json, &resp, |r| {
format!("unbound: {}\n", r.unbound)
})
}
PolicyAction::Disable(args) => {
let resp = client.disable_policy(&args.id).await?;
emit(args.json.json, &resp, |r| {
format!("disabled: {}\n", r.disabled)
})
}
PolicyAction::Enable(args) => {
let resp = client.enable_policy(&args.id).await?;
emit(args.json.json, &resp, |r| {
format!("disabled: {}\n", r.disabled)
})
}
PolicyAction::Bindings(args) => {
let resp = client.list_policy_bindings(&args.id).await?;
emit(args.json.json, &resp, format_binding_list)
}
PolicyAction::Effective(args) => {
let resp = client
.effective_policy(&policies::EffectivePolicyQuery {
principal_type: args.principal_type,
principal_id: args.principal_id,
})
.await?;
emit(args.json.json, &resp, format_effective_policy)
}
PolicyAction::ForPrincipal(args) => {
let resp = client
.list_principal_policies(&args.principal_type, &args.principal_id)
.await?;
emit(args.json.json, &resp, format_policy_list)
}
}
}
fn format_policy_list(resp: &policies::PolicyListResponse) -> String {
if resp.data.is_empty() {
return "no policies".to_owned();
}
let mut out = String::new();
for p in &resp.data {
let disabled = if p.disabled_at.is_some() {
" (disabled)"
} else {
""
};
out.push_str(&format!(
"{:<24} {:<24} kind={}{}\n",
p.id,
p.name,
p.kind.as_str(),
disabled,
));
}
out
}
fn format_policy_one(p: &policies::PolicyEnvelope) -> String {
let mut out = format!(
"id: {}\nname: {}\nkind: {}\n",
p.id,
p.name,
p.kind.as_str(),
);
if let Some(d) = p.disabled_at {
out.push_str(&format!("disabled_at: {}\n", d.to_rfc3339()));
}
out.push_str(&format!(
"spec:\n{}\n",
serde_json::to_string_pretty(&p.spec).unwrap_or_else(|_| "{}".to_owned())
));
out
}
fn format_binding_list(resp: &policies::BindingListResponse) -> String {
if resp.data.is_empty() {
return "no bindings".to_owned();
}
let mut out = String::new();
for b in &resp.data {
out.push_str(&format!(
"{:<24} policy={:<24} principal={}:{}\n",
b.id, b.policy_id, b.principal_type, b.principal_id,
));
}
out
}
fn format_effective_policy(p: &policies::EffectivePolicy) -> String {
let mut out = String::new();
out.push_str(&format!("budgets: {} entries\n", p.budgets.len()));
out.push_str(&format!("rate_limits: {} entries\n", p.rate_limits.len()));
out.push_str(&format!(
"guardrail: {}\n",
if p.guardrail.is_some() { "set" } else { "—" }
));
if !p.budgets.is_empty() || !p.rate_limits.is_empty() || p.guardrail.is_some() {
out.push_str("\nfull body:\n");
if let Ok(pretty) = serde_json::to_string_pretty(p) {
out.push_str(&pretty);
out.push('\n');
}
}
out
}
async fn run_budget(
action: BudgetAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
BudgetAction::List(flag) => {
let resp = client.list_budgets().await?;
emit(flag.json, &resp, format_budget_list)
}
BudgetAction::Get(args) => {
let resp = client.get_budget(&args.id).await?;
emit(args.json.json, &resp, format_budget_one)
}
BudgetAction::Create(args) => {
let resp = client
.create_budget(&budgets::CreateBudgetRequest {
name: args.name,
window: args.window.into(),
limit_micro_usd: args.limit_micro_usd,
})
.await?;
emit(args.json.json, &resp, format_budget_one)
}
BudgetAction::Update(args) => {
let resp = client
.update_budget(
&args.id,
&budgets::UpdateBudgetRequest {
name: args.name,
window: args.window.map(Into::into),
limit_micro_usd: args.limit_micro_usd,
},
)
.await?;
emit(args.json.json, &resp, format_budget_one)
}
BudgetAction::Delete(args) => {
let resp = client.delete_budget(&args.id).await?;
emit(args.json.json, &resp, |r| {
format!("deleted: {}\n", r.deleted)
})
}
}
}
fn format_budget_list(resp: &budgets::BudgetListResponse) -> String {
if resp.data.is_empty() {
return "no budgets".to_owned();
}
let mut out = String::new();
for b in &resp.data {
out.push_str(&format!(
"{:<24} {:<24} window={:?} limit={} µUSD{}\n",
b.id,
b.name,
b.window,
b.limit_micro_usd,
if b.disabled_at.is_some() {
" (disabled)"
} else {
""
},
));
}
out
}
fn format_budget_one(b: &budgets::BudgetEnvelope) -> String {
let mut out = format!(
"id: {}\nname: {}\nwindow: {:?}\nlimit (µUSD): {}\n",
b.id, b.name, b.window, b.limit_micro_usd,
);
if let Some(d) = b.disabled_at {
out.push_str(&format!("disabled_at: {}\n", d.to_rfc3339()));
}
out
}
async fn run_preset(
action: PresetAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
PresetAction::List(flag) => {
let resp = client.list_presets().await?;
emit(flag.json, &resp, format_preset_list)
}
PresetAction::Get(args) => {
let resp = client.get_preset(&args.id).await?;
emit(args.json.json, &resp, format_preset_one)
}
PresetAction::Create(args) => {
let body = presets::CreatePresetRequest {
name: args.name,
guardrail: opt_json_input(args.guardrail.as_ref())?,
budget: opt_json_input(args.budget.as_ref())?,
rate_limit: opt_json_input(args.rate_limit.as_ref())?,
};
let resp = client.create_preset(&body).await?;
emit(args.json.json, &resp, format_preset_one)
}
PresetAction::Update(args) => {
let body = presets::UpdatePresetRequest {
name: args.name,
guardrail: opt_json_input(args.guardrail.as_ref())?,
budget: opt_json_input(args.budget.as_ref())?,
rate_limit: opt_json_input(args.rate_limit.as_ref())?,
clear_guardrail: args.clear_guardrail,
clear_budget: args.clear_budget,
clear_rate_limit: args.clear_rate_limit,
};
let resp = client.update_preset(&args.id, &body).await?;
emit(args.json.json, &resp, format_preset_one)
}
PresetAction::Delete(args) => {
let resp = client.delete_preset(&args.id).await?;
emit(args.json.json, &resp, |r| {
format!("deleted: {}\n", r.deleted)
})
}
}
}
fn format_preset_list(resp: &presets::PresetListResponse) -> String {
if resp.data.is_empty() {
return "no presets".to_owned();
}
let mut out = String::new();
for p in &resp.data {
let clauses = [
("guardrail", p.guardrail.is_some()),
("budget", p.budget.is_some()),
("rate_limit", p.rate_limit.is_some()),
]
.iter()
.filter(|(_, set)| *set)
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(",");
out.push_str(&format!(
"{:<24} {:<24} clauses=[{}]{}\n",
p.id,
p.name,
clauses,
if p.disabled_at.is_some() {
" (disabled)"
} else {
""
},
));
}
out
}
fn format_preset_one(p: &presets::PresetEnvelope) -> String {
let mut out = format!("id: {}\nname: {}\n", p.id, p.name);
if let Some(d) = p.disabled_at {
out.push_str(&format!("disabled_at: {}\n", d.to_rfc3339()));
}
for (label, value) in [
("guardrail", &p.guardrail),
("budget", &p.budget),
("rate_limit", &p.rate_limit),
] {
if let Some(v) = value {
out.push_str(&format!(
"{label}:\n{}\n",
serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_owned())
));
}
}
out
}
async fn run_byok(
action: ByokAction,
manager: Arc<CredentialManager>,
) -> std::result::Result<(), SdkError> {
let client = client(manager).await?;
match action {
ByokAction::List(flag) => {
let resp = client.list_byok_keys().await?;
emit(flag.json, &resp, format_byok_list)
}
ByokAction::Set(args) => {
let body = byok::UpsertByokKeyRequest {
provider_name: args.provider,
ciphertext_b64: args.ciphertext_b64,
kek_id: args.kek_id,
key_prefix: args.key_prefix,
api_base: args.api_base,
};
let resp = client.upsert_byok_key(&body).await?;
emit(args.json.json, &resp, |r| {
format!(
"provider: {}\nkek_id: {}\nprefix: {}\n",
r.provider_name, r.kek_id, r.key_prefix,
)
})
}
ByokAction::Delete(args) => {
let resp = client.delete_byok_key(&args.provider).await?;
emit(args.json.json, &resp, |r| {
format!("deleted: {}\n", r.deleted)
})
}
}
}
fn format_byok_list(resp: &byok::ByokKeyListResponse) -> String {
if resp.data.is_empty() {
return "no byok keys".to_owned();
}
let mut out = String::new();
for k in &resp.data {
out.push_str(&format!(
"{:<20} prefix={:<10} kek={:<24} last_used={}\n",
k.provider_name,
k.key_prefix,
k.kek_id,
k.last_used_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "—".into()),
));
}
out
}
static CLOUD_FORMAT: std::sync::OnceLock<crate::output::Format> = std::sync::OnceLock::new();
fn effective_json(leaf_json: bool) -> bool {
if leaf_json {
return true;
}
!matches!(CLOUD_FORMAT.get(), Some(crate::output::Format::Human))
}
fn emit<T, F>(leaf_json: bool, value: &T, fmt: F) -> std::result::Result<(), SdkError>
where
T: serde::Serialize,
F: FnOnce(&T) -> String,
{
if effective_json(leaf_json) {
let pretty = serde_json::to_string_pretty(value)?;
println!("{pretty}");
} else {
let text = fmt(value);
if !text.is_empty() {
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
println!("{trimmed}");
}
}
Ok(())
}
fn read_json_input(path: &std::path::Path) -> std::result::Result<serde_json::Value, SdkError> {
use std::io::Read;
let raw = if path == std::path::Path::new("-") {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf).map_err(|e| {
SdkError::Auth(anyhow::Error::new(e).context("reading JSON from stdin"))
})?;
buf
} else {
std::fs::read_to_string(path).map_err(|e| {
SdkError::Auth(
anyhow::Error::new(e).context(format!("reading JSON from {}", path.display())),
)
})?
};
serde_json::from_str::<serde_json::Value>(&raw).map_err(SdkError::from)
}
fn opt_json_input(
path: Option<&PathBuf>,
) -> std::result::Result<Option<serde_json::Value>, SdkError> {
match path {
Some(p) => Ok(Some(read_json_input(p)?)),
None => Ok(None),
}
}
fn split_scope_args(scopes: &[String]) -> Vec<String> {
let mut out = Vec::new();
for raw in scopes {
for token in raw.split_whitespace() {
if !token.is_empty() {
out.push(token.to_owned());
}
}
}
out
}
async fn print_error_hint(err: &SdkError, manager: &CredentialManager) {
match err {
SdkError::NotSignedIn => {
eprintln!();
eprintln!(" Sign in first:");
eprintln!(" {} cloud login", bitrouter_sdk::invocation::name());
}
SdkError::Forbidden {
missing_scope: Some(scope),
..
} => {
eprintln!();
eprintln!(" This command requires the scope: {scope}");
let credential = manager.current().await.ok().flatten();
eprintln!(" {}", missing_scope_hint(scope, credential.as_ref()));
}
_ => {}
}
}
fn missing_scope_hint(missing: &str, credential: Option<&StoredCredential>) -> String {
if credential.is_some_and(|credential| credential.kind() == CredentialKind::ApiKey) {
return format!(
"Mint or select an API key with the `{missing}` scope, then run \
`{cli} cloud login --api-key \"$BITROUTER_API_KEY\"`.",
cli = bitrouter_sdk::invocation::name()
);
}
let current = credential.and_then(StoredCredential::scope);
let extended = match current {
Some(scope) if scope.split_whitespace().any(|value| value == missing) => scope.to_owned(),
Some(scope) => format!("{scope} {missing}"),
None => format!("<your current scope> {missing}"),
};
format!(
"Re-run `{cli} cloud login --scope \"{extended}\"` to add it.",
cli = bitrouter_sdk::invocation::name()
)
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct CloudHarness {
#[command(subcommand)]
action: CloudAction,
}
#[test]
fn login_accepts_api_key() {
let parsed = CloudHarness::try_parse_from([
"test",
"login",
"--api-key",
"brk_AAAAAAAAAAAAAAAA.secret",
])
.unwrap();
match parsed.action {
CloudAction::Login { api_key, .. } => {
assert_eq!(api_key.as_deref(), Some("brk_AAAAAAAAAAAAAAAA.secret"));
}
_ => panic!("expected login action"),
}
}
#[test]
fn api_key_conflicts_with_oauth_customization() {
let parsed = CloudHarness::try_parse_from([
"test",
"login",
"--api-key",
"brk_AAAAAAAAAAAAAAAA.secret",
"--client-id",
"custom",
]);
let error = match parsed {
Ok(_) => panic!("conflicting login flags must be rejected"),
Err(error) => error,
};
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn cloud_api_subcommand_is_wired() {
let parsed =
CloudHarness::try_parse_from(["test", "api", "/v1/models", "--include"]).unwrap();
match parsed.action {
CloudAction::Api(args) => {
assert_eq!(args.endpoint, "/v1/models");
assert!(args.include);
}
_ => panic!("expected API action"),
}
}
#[test]
fn missing_scope_guidance_keeps_api_key_users_on_api_keys() {
let credential = StoredCredential::api_key(
"brk_test.fixture".to_owned(),
"https://api.bitrouter.ai".to_owned(),
);
let hint = missing_scope_hint("keys:write", Some(&credential));
assert!(hint.contains("API key"));
assert!(hint.contains("keys:write"));
assert!(hint.contains("bro cloud login --api-key"));
assert!(!hint.contains("--scope"));
assert!(!hint.contains("brk_test.fixture"));
}
}