Skip to content
Merged
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
16 changes: 16 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ CLI: `codexbar usage --source auto|web|cli|oauth`.

Auth resolution helpers in `rust/src/providers/` commonly try: explicit settings → keyring/entry → environment variables (exact order is provider-specific).

### Devin manual authentication

On Windows, Devin uses a manually pasted Bearer token; Chrome-session import is
not available. In Devin, open Developer Tools → Network, reload **Usage &
Limits**, then copy the `Authorization` value from a successful
billing/quota/usage request. In Settings → Providers → Devin, paste a bare
token, a `Bearer ...` value, or the full `Authorization: Bearer ...` line into
the token field. Never share this token.

Set **Organization** to the internal `org-...` or `org_...` ID, an organization
slug, or a `devin.ai` organization URL. The internal ID from the
`x-cog-org-id` header on a successful quota request is the most direct choice.
Environment variables are `DEVIN_BEARER_TOKEN`, `DEVIN_AUTHORIZATION`, or
`DEVIN_API_KEY` for the token, and `DEVIN_ORGANIZATION` or `DEVIN_ORG` for the
organization.

## Cookie-backed providers

Windows browser import: Chrome, Edge, Brave (DPAPI + AES-GCM), Firefox (SQLite).
Expand Down
2 changes: 1 addition & 1 deletion rust/src/providers/alibabatokenplan/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ fn reset_date(value: Option<&Value>) -> Option<chrono::DateTime<Utc>> {
return None;
}
let rounded = milliseconds.round();
if rounded < 1.0 || rounded >= 9_223_372_036_854_775_808.0 {
if !(1.0..9_223_372_036_854_775_808.0).contains(&rounded) {
return None;
}
let millis = format!("{rounded:.0}").parse::<i64>().ok()?;
Expand Down
173 changes: 154 additions & 19 deletions rust/src/providers/devin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use crate::core::{
const CREDENTIAL_TARGET: &str = "codexbar-devin";
const BASE_URLS: [&str; 2] = ["https://api.devin.ai", "https://app.devin.ai/api"];
const MISSING_ORGANIZATION_DETAIL: &str = "No organizations found for auth1 user";
const MISSING_ORGANIZATION_MESSAGE: &str = "Devin organization context is missing. Set the organization in provider extras or DEVIN_ORG, then refresh.";
const MISSING_ORGANIZATION_MESSAGE: &str = "No Devin organization was found. Set Organization (provider extras or DEVIN_ORG) to the internal org-... or org_... ID from a successful quota request's x-cog-org-id header, then refresh.";
const MISSING_ORGANIZATION_CONFIG_MESSAGE: &str = "Devin organization not found; set it in provider extras, DEVIN_ORGANIZATION, or DEVIN_ORG using the internal ID from a successful quota request's x-cog-org-id header.";
const MISSING_TOKEN_MESSAGE: &str = "Devin Bearer token not found. In app.devin.ai open Developer Tools > Network, reload Usage & Limits, select a successful billing/quota/usage request and copy its Authorization value (a leading 'Bearer ' is accepted) into the Devin token field in Preferences, or set DEVIN_BEARER_TOKEN / DEVIN_AUTHORIZATION.";

pub struct DevinProvider {
metadata: ProviderMetadata,
Expand Down Expand Up @@ -60,21 +62,26 @@ impl Provider for DevinProvider {
async fn fetch_usage(&self, ctx: &FetchContext) -> Result<ProviderFetchResult, ProviderError> {
match ctx.source_mode {
SourceMode::Auto | SourceMode::OAuth => {
let token = crate::providers::resolve_api_key(
let token = resolved_manual_token(crate::providers::resolve_api_key(
ctx.api_key.as_deref(),
CREDENTIAL_TARGET,
&["DEVIN_BEARER_TOKEN", "DEVIN_API_KEY"],
)?;
let env_org = std::env::var("DEVIN_ORG").ok();
&["DEVIN_BEARER_TOKEN", "DEVIN_AUTHORIZATION", "DEVIN_API_KEY"],
))?;
let env_org = std::env::var("DEVIN_ORGANIZATION")
.ok()
.filter(|org| !org.trim().is_empty())
.or_else(|| {
std::env::var("DEVIN_ORG")
.ok()
.filter(|org| !org.trim().is_empty())
});
let raw_org = ctx
.workspace_id
.as_deref()
.filter(|org| !org.trim().is_empty())
.or(env_org.as_deref())
.ok_or_else(|| {
ProviderError::NotInstalled(
"Devin organization not found. Set it in provider extras or DEVIN_ORG."
.into(),
)
ProviderError::NotInstalled(MISSING_ORGANIZATION_CONFIG_MESSAGE.into())
})?;
let org = normalized_org(raw_org);
fetch_quota(&self.client, &token, &org, devin_urls(&org)?).await
Expand All @@ -90,6 +97,35 @@ impl Provider for DevinProvider {
}
}

fn resolved_manual_token(raw: Result<String, ProviderError>) -> Result<String, ProviderError> {
match raw {
Ok(raw) => manual_bearer_token(&raw).ok_or_else(missing_token_error),
Err(ProviderError::NotInstalled(_)) => Err(missing_token_error()),
Err(error) => Err(error),
}
}

fn manual_bearer_token(raw: &str) -> Option<String> {
let mut token = raw.trim();
if token
.get(.."Authorization:".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("Authorization:"))
{
token = token["Authorization:".len()..].trim();
}
if token
.get(.."Bearer ".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("Bearer "))
{
token = token["Bearer ".len()..].trim();
}
(!token.is_empty()).then(|| token.to_string())
}

fn missing_token_error() -> ProviderError {
ProviderError::NotInstalled(MISSING_TOKEN_MESSAGE.into())
}

async fn fetch_quota(
client: &Client,
token: &str,
Expand Down Expand Up @@ -206,7 +242,26 @@ fn auth_response_error(status: reqwest::StatusCode, body: &[u8]) -> Option<Provi
fn normalized_org(raw: &str) -> String {
// Both hosts serve the quota at /{org}/billing/quota/usage with the bare
// organization id (org_...); a prefixed path 404s server-side.
let trimmed = raw.trim().trim_matches('/');
let trimmed = raw.trim();
let organization_from_url = Url::parse(trimmed).ok().and_then(|url| {
let host = url.host_str()?.to_ascii_lowercase();
if host != "devin.ai" && !host.ends_with(".devin.ai") {
return None;
}
let mut segments = url.path_segments()?;
let prefix = segments.next()?;
if prefix != "org" && prefix != "organizations" {
return None;
}
segments
.next()
.filter(|organization| !organization.is_empty())
.map(str::to_owned)
});
let trimmed = organization_from_url
.as_deref()
.unwrap_or(trimmed)
.trim_matches('/');
trimmed
.strip_prefix("organizations/")
.or_else(|| trimmed.strip_prefix("org/"))
Expand Down Expand Up @@ -335,16 +390,20 @@ mod tests {

#[test]
fn keeps_unrelated_authorization_failures_as_auth_required() {
for body in [
br#"{"detail":"Unauthorized","trace":"private-trace"}"#.as_slice(),
br#"{"detail":"Token expired","trace":"private-trace"}"#.as_slice(),
br#"{"detail":"No organizations found for another user"}"#.as_slice(),
b"not-json".as_slice(),
for status in [
reqwest::StatusCode::UNAUTHORIZED,
reqwest::StatusCode::FORBIDDEN,
] {
let error = auth_response_error(reqwest::StatusCode::UNAUTHORIZED, body)
.expect("authorization error");
assert!(matches!(error, ProviderError::AuthRequired));
assert_eq!(error.to_string(), "Authentication required");
for body in [
br#"{"detail":"Unauthorized","trace":"private-trace"}"#.as_slice(),
br#"{"detail":"Token expired","trace":"private-trace"}"#.as_slice(),
br#"{"detail":"No organizations found for another user"}"#.as_slice(),
b"not-json".as_slice(),
] {
let error = auth_response_error(status, body).expect("authorization error");
assert!(matches!(error, ProviderError::AuthRequired));
assert_eq!(error.to_string(), "Authentication required");
}
}
}

Expand All @@ -362,6 +421,58 @@ mod tests {
assert_eq!(normalized_org("organizations/org_TJ2demo"), "org_TJ2demo");
}

#[test]
fn manual_bearer_token_accepts_pasted_authorization_values() {
for raw in [
"secret-token",
"Bearer secret-token",
"bearer secret-token",
"Authorization: Bearer secret-token",
" authorization:bearer secret-token ",
] {
assert_eq!(manual_bearer_token(raw).as_deref(), Some("secret-token"));
}
assert_eq!(manual_bearer_token("Bearer").as_deref(), Some("Bearer"));
assert_eq!(manual_bearer_token("Authorization:"), None);
assert_eq!(manual_bearer_token(" "), None);
}

#[test]
fn missing_or_empty_resolved_token_uses_devin_manual_guidance() {
for raw in [
Err(ProviderError::NotInstalled("generic key error".into())),
Ok("Authorization: ".into()),
] {
let error = resolved_manual_token(raw).expect_err("the token is missing");
assert!(
matches!(&error, ProviderError::NotInstalled(message) if message == MISSING_TOKEN_MESSAGE)
);
}
}

#[test]
fn normalized_org_accepts_devin_organization_urls_only() {
for (raw, expected) in [
(
"https://app.devin.ai/org/example-org/settings/usage",
"example-org",
),
(
"https://app.devin.ai/organizations/org_TJ2demo",
"org_TJ2demo",
),
("https://devin.ai/org/foo/", "foo"),
("org_TJ2demo", "org_TJ2demo"),
(" org_TJ2demo/ ", "org_TJ2demo"),
("org/org_TJ2demo", "org_TJ2demo"),
("organizations/org_TJ2demo", "org_TJ2demo"),
("https://evil.example/org/x", "https://evil.example/org/x"),
("https://notdevin.ai/org/x", "https://notdevin.ai/org/x"),
] {
assert_eq!(normalized_org(raw), expected, "raw organization {raw:?}");
}
}

#[test]
fn devin_urls_use_bare_org_on_both_hosts() {
let org = normalized_org("org/org_TJ2demo");
Expand Down Expand Up @@ -425,6 +536,30 @@ mod tests {
assert_eq!(result.usage.primary.used_percent, 25.0);
}

#[tokio::test]
async fn pasted_authorization_and_devin_url_are_normalized_in_quota_request() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/org_TJ2demo/billing/quota/usage")
.match_header("authorization", "Bearer fixture-token")
.match_header("x-cog-org-id", "org_TJ2demo")
.with_status(200)
.with_body(r#"{"daily_percentage":0.25}"#)
.create_async()
.await;
let url = Url::parse(&format!("{}/org_TJ2demo/billing/quota/usage", server.url()))
.expect("the mock server URL should be valid");
let token = manual_bearer_token("Authorization: Bearer fixture-token")
.expect("the pasted authorization value should contain a token");
let org = normalized_org("https://app.devin.ai/org/org_TJ2demo/settings/usage");

fetch_quota(&test_client(), &token, &org, [url])
.await
.expect("the normalized credentials should authenticate");

mock.assert_async().await;
}

#[tokio::test]
async fn mixed_auth_and_server_failures_do_not_become_auth_required() {
let (_first_server, first_url) = quota_mock(401, r#"{"detail":"Unauthorized"}"#).await;
Expand Down
4 changes: 2 additions & 2 deletions rust/src/providers/kiro/usage_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,9 @@ fn endpoint_for_profile_arn(profile_arn: &str) -> Option<&'static str> {
|| fields[0] != "arn"
|| fields[1] != "aws"
|| fields[2] != "codewhisperer"
|| !fields[5]
|| fields[5]
.strip_prefix("profile/")
.is_some_and(|name| !name.is_empty())
.is_none_or(|name| name.is_empty())
{
return None;
}
Expand Down
4 changes: 3 additions & 1 deletion rust/src/providers/openai/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ pub fn parse_subscription_value(value: &Value) -> OpenAISubscriptionFetchResult
)))
}
(None, Some(false)) => OpenAISubscriptionFetchResult::Success(
(!starts_at.is_none()).then(|| SubscriptionMetadata::new(starts_at, None, None)),
starts_at
.is_some()
.then(|| SubscriptionMetadata::new(starts_at, None, None)),
),
// A renewal without an explicit active-until date is not safe to
// represent as a date, even if a plan is known.
Expand Down