Skip to content

Commit 4089a8f

Browse files
committed
Merge branch 'main' of https://github.com/braintrustdata/bt into cedric-better-dx-pr3
2 parents b75aae8 + 92730b8 commit 4089a8f

24 files changed

Lines changed: 5353 additions & 40 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bt"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition = "2021"
55
rust-version = "1.86.0"
66
authors = ["Braintrust engineering <eng@braintrust.dev>"]

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,11 @@ Local transaction-id conversion helpers:
239239

240240
Auth resolution order for commands is:
241241

242-
1. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set)
243-
2. `--profile` or `BRAINTRUST_PROFILE`
244-
3. Org-based profile match (profile whose org matches `--org`/config org)
245-
4. Single-profile auto-select (if only one profile exists)
242+
1. Explicit `--profile`
243+
2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set)
244+
3. `BRAINTRUST_PROFILE`
245+
4. Org-based profile match (profile whose org matches `--org`/config org)
246+
5. Single-profile auto-select (if only one profile exists)
246247

247248
On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If a secure store is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions.
248249

src/args.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::ffi::OsString;
12
use std::path::PathBuf;
23

34
use clap::Args;
@@ -39,6 +40,9 @@ pub struct BaseArgs {
3940
#[arg(long, env = "BRAINTRUST_PROFILE", global = true)]
4041
pub profile: Option<String>,
4142

43+
#[arg(skip = false)]
44+
pub profile_explicit: bool,
45+
4246
/// Override active org (or via BRAINTRUST_ORG_NAME)
4347
#[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)]
4448
pub org_name: Option<String>,
@@ -100,3 +104,64 @@ pub struct CLIArgs<T: Args> {
100104
#[command(flatten)]
101105
pub args: T,
102106
}
107+
108+
pub fn has_explicit_profile_arg(args: &[OsString]) -> bool {
109+
let mut idx = 1usize;
110+
while idx < args.len() {
111+
let Some(arg) = args[idx].to_str() else {
112+
idx += 1;
113+
continue;
114+
};
115+
116+
if arg == "--" {
117+
break;
118+
}
119+
120+
if arg == "--profile" || arg.starts_with("--profile=") {
121+
return true;
122+
}
123+
124+
idx += 1;
125+
}
126+
127+
false
128+
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::has_explicit_profile_arg;
133+
use std::ffi::OsString;
134+
135+
#[test]
136+
fn has_explicit_profile_arg_detects_split_flag() {
137+
let args = vec![
138+
OsString::from("bt"),
139+
OsString::from("status"),
140+
OsString::from("--profile"),
141+
OsString::from("work"),
142+
];
143+
assert!(has_explicit_profile_arg(&args));
144+
}
145+
146+
#[test]
147+
fn has_explicit_profile_arg_detects_equals_flag() {
148+
let args = vec![
149+
OsString::from("bt"),
150+
OsString::from("status"),
151+
OsString::from("--profile=work"),
152+
];
153+
assert!(has_explicit_profile_arg(&args));
154+
}
155+
156+
#[test]
157+
fn has_explicit_profile_arg_ignores_passthrough_args() {
158+
let args = vec![
159+
OsString::from("bt"),
160+
OsString::from("eval"),
161+
OsString::from("--"),
162+
OsString::from("--profile"),
163+
OsString::from("work"),
164+
];
165+
assert!(!has_explicit_profile_arg(&args));
166+
}
167+
}

src/auth.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,16 @@ fn maybe_warn_api_key_override(base: &BaseArgs) {
554554
}
555555
}
556556

557+
fn has_explicit_profile_selection(base: &BaseArgs) -> bool {
558+
base.profile_explicit
559+
&& base
560+
.profile
561+
.as_deref()
562+
.is_some_and(|value| !value.trim().is_empty())
563+
}
564+
557565
fn resolve_api_key_override(base: &BaseArgs) -> Option<String> {
558-
if base.prefer_profile
566+
if (base.prefer_profile || has_explicit_profile_selection(base))
559567
&& !matches!(
560568
base.api_key_source,
561569
Some(crate::args::ArgValueSource::CommandLine)
@@ -2958,6 +2966,7 @@ mod tests {
29582966
no_color: false,
29592967
no_input: false,
29602968
profile: None,
2969+
profile_explicit: false,
29612970
project: None,
29622971
org_name: None,
29632972
api_key: None,
@@ -3318,6 +3327,38 @@ mod tests {
33183327
assert_eq!(resolved.org_name, None);
33193328
}
33203329

3330+
#[test]
3331+
fn resolve_auth_explicit_profile_ignores_env_api_key_override() {
3332+
let mut base = make_base();
3333+
base.api_key = Some("explicit-key".to_string());
3334+
base.profile = Some("work".to_string());
3335+
base.profile_explicit = true;
3336+
3337+
let mut store = AuthStore::default();
3338+
store.profiles.insert(
3339+
"work".to_string(),
3340+
AuthProfile {
3341+
auth_kind: AuthKind::ApiKey,
3342+
api_url: Some("https://api.example.com".to_string()),
3343+
app_url: None,
3344+
org_name: Some("Example Org".to_string()),
3345+
oauth_client_id: None,
3346+
oauth_access_expires_at: None,
3347+
..Default::default()
3348+
},
3349+
);
3350+
3351+
let resolved = resolve_auth_from_store_with_secret_lookup(
3352+
&base,
3353+
&store,
3354+
|_| Ok(Some("profile-key".to_string())),
3355+
&None,
3356+
)
3357+
.expect("resolve");
3358+
assert_eq!(resolved.api_key.as_deref(), Some("profile-key"));
3359+
assert_eq!(resolved.org_name.as_deref(), Some("Example Org"));
3360+
}
3361+
33213362
#[test]
33223363
fn resolve_auth_marks_oauth_profiles() {
33233364
let mut base = make_base();

src/eval.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ struct DevServerState {
181181
struct DevAuthContext {
182182
token: String,
183183
org_name: String,
184+
api_url: Option<String>,
184185
}
185186

186187
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1084,26 +1085,33 @@ async fn authenticate_dev_request(
10841085

10851086
let payload = response.json::<Value>().await.unwrap_or(Value::Null);
10861087
if let Some(orgs) = payload.get("org_info").and_then(|value| value.as_array()) {
1087-
let matched = orgs.iter().any(|org| {
1088+
let matched_org = orgs.iter().find(|org| {
10881089
org.get("name")
10891090
.and_then(|name| name.as_str())
10901091
.map(|name| name == org_name)
10911092
.unwrap_or(false)
10921093
});
1093-
if !matched {
1094+
let Some(matched_org) = matched_org else {
10941095
return Err(json_error_response(
10951096
actix_web::http::StatusCode::UNAUTHORIZED,
10961097
"Unauthorized",
10971098
));
1098-
}
1099+
};
1100+
let api_url = matched_org
1101+
.get("api_url")
1102+
.and_then(|value| value.as_str())
1103+
.map(str::to_string);
1104+
Ok(DevAuthContext {
1105+
token,
1106+
org_name,
1107+
api_url,
1108+
})
10991109
} else {
1100-
return Err(json_error_response(
1110+
Err(json_error_response(
11011111
actix_web::http::StatusCode::UNAUTHORIZED,
11021112
"Unauthorized",
1103-
));
1113+
))
11041114
}
1105-
1106-
Ok(DevAuthContext { token, org_name })
11071115
}
11081116

11091117
async fn resolve_dataset_ref_for_eval_request(
@@ -1218,6 +1226,9 @@ fn make_dev_mode_env(
12181226
("BRAINTRUST_APP_URL".to_string(), state.app_url.clone()),
12191227
("BT_EVAL_DEV_MODE".to_string(), dev_mode.to_string()),
12201228
];
1229+
if let Some(api_url) = auth.api_url.as_ref() {
1230+
env.push(("BRAINTRUST_API_URL".to_string(), api_url.clone()));
1231+
}
12211232
if let Some(request) = request {
12221233
let serialized =
12231234
serde_json::to_string(request).context("failed to serialize eval request payload")?;

src/functions/push.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3439,6 +3439,7 @@ mod tests {
34393439
no_color: false,
34403440
no_input: false,
34413441
profile: None,
3442+
profile_explicit: false,
34423443
org_name: None,
34433444
project: None,
34443445
api_key: None,

src/http.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ impl ApiClient {
103103
response.json().await.context("failed to parse response")
104104
}
105105

106+
pub async fn patch<T: DeserializeOwned, B: Serialize>(
107+
&self,
108+
path: &str,
109+
body: &B,
110+
) -> Result<T> {
111+
let url = self.url(path);
112+
let response = self
113+
.http
114+
.patch(&url)
115+
.bearer_auth(&self.api_key)
116+
.json(body)
117+
.send()
118+
.await
119+
.context("request failed")?;
120+
121+
if !response.status().is_success() {
122+
let status = response.status();
123+
let body = response.text().await.unwrap_or_default();
124+
return Err(HttpError { status, body }.into());
125+
}
126+
127+
response.json().await.context("failed to parse response")
128+
}
129+
106130
pub async fn post_with_headers<T, B>(
107131
&self,
108132
path: &str,

src/main.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ mod status;
2626
mod switch;
2727
mod sync;
2828
mod tools;
29+
mod topics;
2930
mod traces;
3031
mod ui;
3132
mod util_cmd;
3233
mod utils;
3334

34-
use crate::args::{ArgValueSource, BaseArgs, CLIArgs};
35+
use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs};
3536

3637
const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev");
3738
const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") {
@@ -59,6 +60,7 @@ Core
5960
6061
Projects & resources
6162
projects Manage projects
63+
topics Inspect and control Topics automation
6264
prompts Manage prompts
6365
functions Manage functions (tools, scorers, and more)
6466
tools Manage tools
@@ -130,6 +132,8 @@ enum Commands {
130132
Eval(CLIArgs<eval::EvalArgs>),
131133
/// Manage projects
132134
Projects(CLIArgs<projects::ProjectsArgs>),
135+
/// Inspect and control Topics automation
136+
Topics(CLIArgs<topics::TopicsArgs>),
133137
/// Manage prompts
134138
Prompts(CLIArgs<prompts::PromptsArgs>),
135139
#[command(name = "self")]
@@ -167,6 +171,7 @@ impl Commands {
167171
#[cfg(unix)]
168172
Commands::Eval(cmd) => &cmd.base,
169173
Commands::Projects(cmd) => &cmd.base,
174+
Commands::Topics(cmd) => &cmd.base,
170175
Commands::Prompts(cmd) => &cmd.base,
171176
Commands::SelfCommand(cmd) => &cmd.base,
172177
Commands::Tools(cmd) => &cmd.base,
@@ -191,6 +196,7 @@ impl Commands {
191196
#[cfg(unix)]
192197
Commands::Eval(cmd) => &mut cmd.base,
193198
Commands::Projects(cmd) => &mut cmd.base,
199+
Commands::Topics(cmd) => &mut cmd.base,
194200
Commands::Prompts(cmd) => &mut cmd.base,
195201
Commands::SelfCommand(cmd) => &mut cmd.base,
196202
Commands::Tools(cmd) => &mut cmd.base,
@@ -239,6 +245,7 @@ async fn try_main() -> Result<()> {
239245
let matches = Cli::command().get_matches_from(&argv);
240246
let mut cli = Cli::from_arg_matches(&matches).expect("clap matches should parse");
241247
apply_base_arg_sources(&matches, cli.command.base_mut());
248+
cli.command.base_mut().profile_explicit = has_explicit_profile_arg(&argv);
242249
apply_base_output_defaults(&mut cli.command);
243250
configure_output(cli.command.base());
244251

@@ -252,6 +259,7 @@ async fn try_main() -> Result<()> {
252259
#[cfg(unix)]
253260
Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?,
254261
Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?,
262+
Commands::Topics(cmd) => topics::run(cmd.base, cmd.args).await?,
255263
Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?,
256264
Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?,
257265
Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?,

src/setup/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3915,6 +3915,7 @@ mod tests {
39153915
no_color: false,
39163916
no_input: false,
39173917
profile: None,
3918+
profile_explicit: false,
39183919
org_name: None,
39193920
project: None,
39203921
api_key: None,

0 commit comments

Comments
 (0)