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
52 changes: 51 additions & 1 deletion crates/openab-core/src/acp/connection.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::acp::protocol::{
parse_config_options, ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse,
parse_config_options, parse_usage_report, ConfigOption, JsonRpcMessage, JsonRpcRequest,
JsonRpcResponse, UsageReport,
};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
Expand Down Expand Up @@ -179,6 +180,9 @@ pub struct AcpConnection {
notify_tx: Arc<Mutex<Option<mpsc::UnboundedSender<JsonRpcMessage>>>>,
pub acp_session_id: Option<String>,
pub supports_load_session: bool,
/// Agent name from `initialize` (`agentInfo.name`), e.g. "Kiro CLI Agent".
/// Used to gate agent-specific extension methods.
pub agent_name: String,
pub config_options: Vec<ConfigOption>,
pub last_active: Instant,
pub activity: Arc<SessionActivity>,
Expand Down Expand Up @@ -474,6 +478,7 @@ impl AcpConnection {
notify_tx,
acp_session_id: None,
supports_load_session: false,
agent_name: String::new(),
config_options: Vec::new(),
last_active: Instant::now(),
activity,
Expand Down Expand Up @@ -543,6 +548,7 @@ impl AcpConnection {
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown");
self.agent_name = agent_name.to_string();
self.supports_load_session = result
.and_then(|r| r.get("agentCapabilities"))
.and_then(|c| c.get("loadSession"))
Expand Down Expand Up @@ -638,6 +644,50 @@ impl AcpConnection {
Ok(self.config_options.clone())
}

/// Query account-level usage/billing via kiro-cli's
/// `_kiro.dev/commands/execute` extension (the `/usage` slash command).
///
/// This is a Kiro-specific extension, not part of the ACP spec, and the
/// request shape is strict: a malformed `command` value is a
/// deserialization error that kills the whole ACP connection (no JSON-RPC
/// error is returned). We therefore gate on the agent name advertised in
/// `initialize` and never retry on failure.
pub async fn get_usage(&mut self) -> Result<UsageReport> {
if !self.agent_name.to_ascii_lowercase().contains("kiro") {
return Err(anyhow!(
"usage query is not supported by this backend ({})",
if self.agent_name.is_empty() {
"unknown agent"
} else {
&self.agent_name
}
));
}
let session_id = self
.acp_session_id
.as_ref()
.ok_or_else(|| anyhow!("no session"))?
.clone();

let resp = self
.send_request(
"_kiro.dev/commands/execute",
Some(json!({
"sessionId": session_id,
// Adjacently-tagged TuiCommand enum: tag = "command", content = "args".
"command": {"command": "usage", "args": {}},
})),
)
.await?;

let result = resp
.result
.as_ref()
.ok_or_else(|| anyhow!("empty usage response"))?;
parse_usage_report(result)
.ok_or_else(|| anyhow!("could not parse usage response from agent"))
}

/// Send a prompt with content blocks (text and/or images) and return a receiver
/// for streaming notifications. The final message on the channel will have id set
/// (the prompt response).
Expand Down
16 changes: 16 additions & 0 deletions crates/openab-core/src/acp/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,22 @@ impl SessionPool {
conn.set_config_option(config_id, value).await
}

/// Query account-level usage/billing from the backend agent for a session
/// (kiro-cli extension). Fails when there is no active session for the
/// thread or the backend does not support usage queries.
pub async fn get_usage(&self, thread_id: &str) -> Result<crate::acp::protocol::UsageReport> {
let conn = {
let state = self.state.read().await;
state
.active
.get(thread_id)
.cloned()
.ok_or_else(|| anyhow!("no connection for thread {thread_id}"))?
};
let mut conn = conn.lock().await;
conn.get_usage().await
}

/// Cancel the current in-flight operation for a session.
/// Uses pre-stored cancel handles to avoid locking the connection (which is held during streaming).
pub async fn cancel_session(&self, thread_id: &str) -> Result<()> {
Expand Down
220 changes: 220 additions & 0 deletions crates/openab-core/src/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,99 @@ pub fn parse_turn_result(result: &Value) -> TurnResult {
}
}

// --- Account usage report (kiro-cli `_kiro.dev/commands/execute` extension) ---

/// One resource-type usage breakdown (e.g. credits) from a usage report.
#[derive(Debug, Clone, PartialEq)]
pub struct UsageBreakdown {
pub display_name: String,
pub used: f64,
/// Plan allowance for this resource. `None` when the account has no
/// per-user cap (`hasLimit: false`, e.g. pooled enterprise credits).
pub limit: Option<f64>,
pub percentage: Option<u64>,
/// Accrued overage charges for this cycle, if any.
pub overage_charges: Option<f64>,
pub currency: Option<String>,
}

/// Account-level usage/billing report returned by kiro-cli's `/usage` command
/// when executed over ACP via `_kiro.dev/commands/execute`.
#[derive(Debug, Clone, PartialEq)]
pub struct UsageReport {
pub plan_name: String,
/// Billing cycle reset date (e.g. "2026-08-01"), if reported.
pub billing_cycle_reset: Option<String>,
pub breakdowns: Vec<UsageBreakdown>,
}

/// Parse a usage report from the result of a `_kiro.dev/commands/execute`
/// request for the `usage` command. Expected shape (kiro-cli 2.12.x):
///
/// ```json
/// {"success": true, "message": "...", "data": {
/// "planName": "KIRO POWER", "billingCycleReset": "2026-08-01",
/// "usageBreakdowns": [{"displayName": "Credits", "used": 128.5,
/// "limit": 10000.0, "percentage": 1, "hasLimit": true,
/// "overageCharges": 0.0, "currency": "USD"}]}}
/// ```
///
/// Returns `None` when `success` is not true or the data shape is missing β€”
/// callers should treat that as "usage not supported by this agent".
pub fn parse_usage_report(result: &Value) -> Option<UsageReport> {
if !result.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
return None;
}
let data = result.get("data")?;
let plan_name = data.get("planName")?.as_str()?.to_string();
let billing_cycle_reset = data
.get("billingCycleReset")
.and_then(|v| v.as_str())
.map(String::from);

let breakdowns = data
.get("usageBreakdowns")
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|b| {
// `hasLimit: false` is an explicit sentinel (pooled/no-cap
// enterprise accounts). Some kiro-cli versions omit the
// field entirely while still returning a numeric `limit`
// (observed live: overage state on kiro-cli in the pr1392
// image) β€” treat a missing `hasLimit` as "has a limit if a
// numeric limit is present" instead of hiding the cap.
let has_limit = b
.get("hasLimit")
.and_then(|v| v.as_bool())
.unwrap_or_else(|| {
b.get("limit").and_then(|v| v.as_f64()).is_some()
});
Some(UsageBreakdown {
display_name: b.get("displayName")?.as_str()?.to_string(),
used: b.get("used")?.as_f64()?,
limit: if has_limit {
b.get("limit").and_then(|v| v.as_f64())
} else {
None
},
percentage: b.get("percentage").and_then(|v| v.as_u64()),
overage_charges: b.get("overageCharges").and_then(|v| v.as_f64()),
currency: b.get("currency").and_then(|v| v.as_str()).map(String::from),
})
})
.collect()
})
.unwrap_or_default();

Some(UsageReport {
plan_name,
billing_cycle_reset,
breakdowns,
})
}

// --- ACP notification classification ---

#[derive(Debug)]
Expand Down Expand Up @@ -495,4 +588,131 @@ mod tests {
let tr = parse_turn_result(&result);
assert!(!tr.is_silent_failure());
}

#[test]
fn parse_usage_report_full() {
let result = json!({
"success": true,
"message": "Plan: KIRO POWER | 1 usage breakdowns",
"data": {
"planName": "KIRO POWER",
"billingCycleReset": "2026-08-01",
"overagesEnabled": true,
"isEnterprise": false,
"usageBreakdowns": [{
"resourceType": "CREDIT",
"displayName": "Credits",
"used": 12781.64,
"limit": 10000.0,
"percentage": 127,
"currentOverages": 2781.64,
"overageRate": 0.04,
"overageCharges": 111.27,
"currency": "USD",
"hasLimit": true
}],
"bonusCredits": [],
"addOnCredits": [],
"overageCapable": true
}
});
let report = parse_usage_report(&result).expect("should parse");
assert_eq!(report.plan_name, "KIRO POWER");
assert_eq!(report.billing_cycle_reset.as_deref(), Some("2026-08-01"));
assert_eq!(report.breakdowns.len(), 1);
let b = &report.breakdowns[0];
assert_eq!(b.display_name, "Credits");
assert_eq!(b.used, 12781.64);
assert_eq!(b.limit, Some(10000.0));
assert_eq!(b.percentage, Some(127));
assert_eq!(b.overage_charges, Some(111.27));
assert_eq!(b.currency.as_deref(), Some("USD"));
}

#[test]
fn parse_usage_report_no_limit_hides_cap() {
// Pooled enterprise credits: hasLimit=false means the backend's
// sentinel limit value must not be surfaced.
let result = json!({
"success": true,
"data": {
"planName": "ENTERPRISE",
"usageBreakdowns": [{
"displayName": "Credits",
"used": 320.0,
"limit": 999999.0,
"hasLimit": false
}]
}
});
let report = parse_usage_report(&result).expect("should parse");
assert_eq!(report.breakdowns[0].limit, None);
assert_eq!(report.billing_cycle_reset, None);
}

#[test]
fn parse_usage_report_missing_has_limit_keeps_numeric_limit() {
// Regression: exact payload captured live from B0 (kiro-cli in the
// pr1392 image, 2026-07-13). This kiro-cli version omits `hasLimit`
// entirely while returning a real numeric `limit` β€” the old
// `unwrap_or(false)` default hid the cap and progress bar even though
// the account was 130% over its 10000-credit limit.
let result = json!({
"success": true,
"message": "Plan: KIRO POWER | 1 usage breakdowns",
"data": {
"planName": "KIRO POWER",
"billingCycleReset": "2026-08-01",
"overagesEnabled": true,
"isEnterprise": false,
"usageBreakdowns": [{
"resourceType": "CREDIT",
"displayName": "Credits",
"used": 13080.52,
"limit": 10000.0,
"percentage": 130,
"currentOverages": 3080.52,
"overageRate": 0.04,
"overageCharges": 123.220870825752,
"currency": "USD"
}],
"bonusCredits": []
}
});
let report = parse_usage_report(&result).expect("should parse");
let b = &report.breakdowns[0];
assert_eq!(b.limit, Some(10000.0));
assert_eq!(b.percentage, Some(130));
}

#[test]
fn parse_usage_report_missing_has_limit_and_missing_limit_hides_cap() {
// No `hasLimit` and no numeric `limit` β†’ still consumption-only.
let result = json!({
"success": true,
"data": {
"planName": "POOLED",
"usageBreakdowns": [{"displayName": "Credits", "used": 320.0}]
}
});
let report = parse_usage_report(&result).expect("should parse");
assert_eq!(report.breakdowns[0].limit, None);
}

#[test]
fn parse_usage_report_failure_returns_none() {
assert!(parse_usage_report(&json!({"success": false})).is_none());
assert!(parse_usage_report(&json!({"success": true})).is_none()); // no data
assert!(parse_usage_report(&json!({})).is_none());
}

#[test]
fn parse_usage_report_empty_breakdowns() {
let result = json!({
"success": true,
"data": {"planName": "FREE", "usageBreakdowns": []}
});
let report = parse_usage_report(&result).expect("should parse");
assert!(report.breakdowns.is_empty());
}
}
Loading
Loading