Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ reqwest = { version = "0.12.7", default-features = false, features = ["json", "r
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
serde_path_to_error = "0.1.20"
yaml_serde = "0.10"
toml = "0.8"
sha2 = "0.10.8"
strip-ansi-escapes = "0.2.0"
Expand Down
105 changes: 90 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,96 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC

## Commands

| Command | Description |
| ------------- | ------------------------------------------------------------------ |
| `bt init` | Initialize `.bt/` config directory and link to a project |
| `bt auth` | Authenticate with Braintrust |
| `bt switch` | Switch org and project context |
| `bt status` | Show current org and project context |
| `bt datasets` | Manage datasets and dataset pipelines |
| `bt eval` | Run eval files (Unix only) |
| `bt sql` | Run SQL queries against Braintrust |
| `bt view` | View logs, traces, and spans |
| `bt projects` | Manage projects (list, create, view, delete) |
| `bt datasets` | Manage remote datasets (list, create, update, view, delete) |
| `bt prompts` | Manage prompts (list, view, delete) |
| `bt sync` | Synchronize project logs between Braintrust and local NDJSON files |
| `bt update` | Update bt in-place |
| Command | Description |
| -------------- | ------------------------------------------------------------------ |
| `bt init` | Initialize `.bt/` config directory and link to a project |
| `bt auth` | Authenticate with Braintrust |
| `bt switch` | Switch org and project context |
| `bt status` | Show current org and project context |
| `bt datasets` | Manage datasets and dataset pipelines |
| `bt eval` | Run eval files (Unix only) |
| `bt sql` | Run SQL queries against Braintrust |
| `bt view` | View logs, traces, and spans |
| `bt projects` | Manage projects (list, create, view, delete) |
| `bt datasets` | Manage remote datasets (list, create, update, view, delete) |
| `bt prompts` | Manage prompts (list, view, update, delete) |
| `bt functions` | Manage functions (list, view, invoke, update, push, pull, delete) |
| `bt tools` | Manage tools (list, view, invoke, update, delete) |
| `bt scorers` | Manage scorers (list, create, view, invoke, update, delete) |
| `bt sync` | Synchronize project logs between Braintrust and local NDJSON files |
| `bt update` | Update bt in-place |

## `bt scorers`

Create and update prompt-based LLM scorers in the current project:

```bash
bt scorers create "Helpfulness" \
--model gpt-5.4-nano \
--messages @messages.json \
--choice-scores '{"A":1,"B":0}'

bt scorers update helpfulness --messages @messages.json
bt scorers update helpfulness --model gpt-5.4-nano
```

`@PATH` and `-` are CLI-only source notation, not scorer settings in the web UI. For example, `--messages @messages.json` reads chat messages from `messages.json`, while `--messages -` reads them from stdin.

LLM scorer configuration mirrors the web UI:

```bash
bt scorers create "Quality judge" \
--model gpt-5.4-nano \
--messages @messages.json \
--choice-scores '{"pass":1,"fail":0}' \
--temperature 0.1 \
--max-tokens 512 \
--top-p 0.9 \
--frequency-penalty 0 \
--presence-penalty 0 \
--stop-sequence END \
--tool-choice auto \
--reasoning-effort none \
--verbosity low \
--template-format mustache \
--pass-threshold 0.7 \
--metadata @metadata.yaml
```

Use `--template-format mustache|jinja|none`; `nunjucks` and `jinja2` are accepted aliases for Jinja. Repeat `--stop-sequence` for multiple values. Tool choice accepts `auto`, `none`, `required`, or a function name. Model parameters are validated against the same model catalog and custom-model metadata used by the web UI, including parameter availability, provider-specific ranges, reasoning options, and output-token limits. Unknown custom models receive only provider-independent validation instead of being assigned capabilities based on their names.

For classification output instead of a numeric score, use classifications in place of choice scores:

```bash
bt scorers create "Safety label" \
--model gpt-5.4-nano \
--messages @messages.json \
--classifications '["safe","unsafe"]' \
--allow-no-match
```

Use `--if-exists error|ignore|replace` when creating a scorer. Text and structured input flags accept an inline value, `@PATH` to read from a file, or `-` for stdin. For fields without a dedicated update flag, use `--patch` with a JSON object.

For code scorers, use the Braintrust SDK for your language and push the source file:

```ts
// TypeScript
import { projects } from "braintrust";
const project = projects.create({ name: "test-project" });
project.scorers.create({ name: "Test scorer", handler: ({ output }) => 1 });
```

```python
# Python
from braintrust import projects
project = projects.create("test-project")
project.scorers.create(name="Test scorer", handler=test_scorer, parameters=ScorerInput)
```

```bash
bt functions push scorer.ts
bt functions push scorer.py
```

## `bt eval`

Expand Down
2 changes: 1 addition & 1 deletion scripts/skill-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Options:

Examples:
scripts/skill-smoke-test.sh --agent codex
scripts/skill-smoke-test.sh --agent codex --agent-cmd 'codex run --prompt-file AGENT_TASK.md'
scripts/skill-smoke-test.sh --agent codex --agent-cmd 'codex exec - < AGENT_TASK.md'
scripts/skill-smoke-test.sh --demo-dir /tmp/bt-skill-demo --verify-only
EOF
}
Expand Down
69 changes: 65 additions & 4 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,15 +557,13 @@ pub async fn login(base: &BaseArgs) -> Result<LoginContext> {
Err(err) => return Err(err.into()),
};

let api_url = login
.api_url()
.or(auth.api_url.clone())
.unwrap_or_else(|| DEFAULT_API_URL.to_string());
let api_url = resolve_login_api_url(auth.api_url.clone(), login.api_url());

let app_url = auth
.app_url
.clone()
.unwrap_or_else(|| DEFAULT_APP_URL.to_string());
let login = normalize_login_state(login, api_key, &api_url, &app_url);

let ctx = LoginContext {
login,
Expand All @@ -576,6 +574,35 @@ pub async fn login(base: &BaseArgs) -> Result<LoginContext> {
Ok(ctx)
}

fn resolve_login_api_url(configured: Option<String>, discovered: Option<String>) -> String {
// The configured CLI/env/profile URL is the request target. Do not let a
// cached or server-returned login URL silently replace it.
configured
.or(discovered)
.unwrap_or_else(|| DEFAULT_API_URL.to_string())
}

fn normalize_login_state(
login: LoginState,
api_key: String,
api_url: &str,
app_url: &str,
) -> LoginState {
// Keep LoginContext's two URL sources consistent. Most commands use
// LoginContext::api_url through ApiClient, but SDK-backed paths may inspect
// LoginState directly.
let normalized = LoginState::new();
let did_set = normalized.set(
api_key,
login.org_id().unwrap_or_default(),
login.org_name().unwrap_or_default(),
api_url.to_string(),
app_url.to_string(),
);
debug_assert!(did_set, "new login state should be unset");
normalized
}

#[derive(Debug, Deserialize)]
struct AiProviderSecret {
#[serde(default)]
Expand Down Expand Up @@ -4001,6 +4028,40 @@ mod tests {
}
}

#[test]
fn configured_urls_override_discovered_login_state() {
let discovered = LoginState::new();
assert!(discovered.set(
"test-api-key".to_string(),
"org_test".to_string(),
"test-org".to_string(),
DEFAULT_API_URL.to_string(),
DEFAULT_APP_URL.to_string(),
));
let api_url = resolve_login_api_url(
Some("https://api.test.example".to_string()),
discovered.api_url(),
);

let normalized = normalize_login_state(
discovered,
"test-api-key".to_string(),
&api_url,
"https://app.test.example",
);

assert_eq!(
normalized.api_url().as_deref(),
Some("https://api.test.example")
);
assert_eq!(
normalized.app_url().as_deref(),
Some("https://app.test.example")
);
assert_eq!(normalized.org_id().as_deref(), Some("org_test"));
assert_eq!(normalized.org_name().as_deref(), Some("test-org"));
}

fn assert_invalid_api_url<T>(result: Result<T>) {
assert_err_contains(result, "invalid api_url");
}
Expand Down
46 changes: 40 additions & 6 deletions src/functions/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,29 @@ pub async fn list_functions(
project_id: &str,
function_type: Option<&str>,
) -> Result<Vec<Function>> {
let query = list_functions_query(project_id, function_type);
let response = client.btql::<Function>(&query).await?;

Ok(response.data)
}

fn list_functions_query(project_id: &str, function_type: Option<&str>) -> String {
let pid = escape_sql(project_id);
let query = match function_type {
let type_filter = match function_type {
// The Braintrust UI lists score-producing scorers and label-producing
// classifiers together in the Scorers section.
Some("scorer") => " AND function_type IN ('scorer', 'classifier')".to_string(),
Some(ft) => {
let ft = escape_sql(ft);
format!("SELECT * FROM project_functions('{pid}') WHERE function_type = '{ft}'")
format!(" AND function_type = '{ft}'")
}
None => format!("SELECT * FROM project_functions('{pid}')"),
None => String::new(),
};
let response = client.btql::<Function>(&query).await?;

Ok(response.data)
// Function definitions can be arbitrarily old, but retain an explicit
// timestamp constraint to keep every BTQL query bounded.
format!(
"SELECT * FROM project_functions('{pid}') WHERE created >= '1970-01-01T00:00:00Z'{type_filter}"
)
}

pub async fn get_function_by_slug(
Expand Down Expand Up @@ -144,6 +156,20 @@ pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()
client.delete(&path).await
}

/// Partially update a function (scorer/tool/prompt/...) by id.
///
/// The Braintrust API deep-merges object fields, so callers can send only the
/// nested fields they want to change (for example `prompt_data.prompt`) without
/// sending the complete function definition.
pub async fn patch_function(
client: &ApiClient,
function_id: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let path = format!("/v1/function/{}", encode(function_id));
client.patch(&path, body).await
}

pub async fn list_functions_page(
client: &ApiClient,
query: &FunctionListQuery,
Expand Down Expand Up @@ -277,6 +303,14 @@ fn ignored_count(raw: &Value) -> Option<usize> {
mod tests {
use super::*;

#[test]
fn scorer_list_query_includes_classifiers_and_a_timestamp_bound() {
let query = list_functions_query("test-project-id", Some("scorer"));

assert!(query.contains("created >= '1970-01-01T00:00:00Z'"));
assert!(query.contains("function_type IN ('scorer', 'classifier')"));
}

#[test]
fn ignored_count_extracts_canonical_shape() {
let first = serde_json::json!({ "ignored_count": 3 });
Expand Down
Loading
Loading