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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to Chutes Build will be documented in this file. The format
is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.11] - 2026-11-08

### Changed

- Chutes now bills pay-as-you-go: `chutes-build account` and `/account` report
**Pay-as-you-go**, omit monthly price/cap values, hide the Plus/Pro plan
reference, and keep the rolling four-hour and daily request windows plus the
PAYG balance.

### Fixed

- `list_media_models` now returns an explicit message when a category has no
available model (for example video), and `/imagine`/`/imagine-video` tell the
user that generation is unavailable instead of inventing a model.

## [2.0.10] - 2026-10-31

### Fixed
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ The TUI degrades decorative glyphs on the legacy Windows console: arrows, checkm

Browser automation needs Chrome or Edge, or an explicit `CHUTES_BROWSER_EXECUTABLE`. Video inspection needs FFmpeg, or an explicit `CHUTES_FFMPEG_EXECUTABLE`; install it and restart the process.

### No model available for a media category

Chutes media models come and go. If the live catalog has no model for a requested category, `/imagine`, `/imagine-video`, and `generate_media` report that no model is currently available and do not invent one. For example, when no video model is published, video generation cannot run until Chutes adds one; the same applies to image, music, and speech. Pick another category or retry later.

## Development

Clone the repository and build with the pinned toolchain:
Expand Down
92 changes: 73 additions & 19 deletions crates/chutes-build-core/src/account_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ fn normalize_usage_windows(
);
let label = pick_string(nested, payload, &[], &["label"])
.unwrap_or_else(|| candidate.label.to_string());
if nested.is_none() && used.is_none() && limit.is_none() && remaining.is_none() {
if used.is_none() && limit.is_none() && remaining.is_none() {
return None;
}
Some(UsageWindow {
Expand Down Expand Up @@ -600,24 +600,11 @@ fn normalize_plan(payload: &serde_json::Value, windows: &[UsageWindow]) -> Optio
let get = |map: &serde_json::Map<String, serde_json::Value>, key: &str| {
map.get(key).and_then(|v| as_number(Some(v)))
};
let plan_name = source
.get("name")
.and_then(|v| as_string(Some(v)))
.or_else(|| as_string(object.get("plan_name")))
.or_else(|| derive_plan_name(object));
let monthly_price_usd =
get(source, "monthly_price").or_else(|| as_number(object.get("monthly_price")));
let monthly_cap_usd = get(source, "monthly_cap_usd")
.or_else(|| as_number(object.get("monthly_cap_usd")))
.or_else(|| as_number(object.get("billing_cycle_limit")))
.or_else(|| {
object
.get("monthly")
.and_then(|v| v.as_object())
.and_then(|inner| as_number(inner.get("cap")))
})
.or_else(|| billing_cap.and_then(|inner| as_number(inner.get("limit"))))
.or_else(|| billing_window.and_then(|w| w.limit));
let monthly = object.get("monthly").and_then(|v| v.as_object());
let pay_as_you_go = monthly
.and_then(|inner| inner.get("uncapped"))
.and_then(|v| v.as_bool())
== Some(true);
let four_hour_cap_usd = get(source, "four_hour_cap_usd")
.or_else(|| as_number(object.get("four_hour_cap_usd")))
.or_else(|| as_number(object.get("four_hour_limit")))
Expand All @@ -637,6 +624,31 @@ fn normalize_plan(payload: &serde_json::Value, windows: &[UsageWindow]) -> Optio
let payg_discount_percent = get(source, "payg_discount_percent")
.or_else(|| as_number(object.get("payg_discount_percent")));

if pay_as_you_go {
return Some(PlanInfo {
plan_name: Some("Pay-as-you-go".to_string()),
monthly_price_usd: None,
monthly_cap_usd: None,
four_hour_cap_usd,
daily_request_limit,
payg_discount_percent,
});
}

let plan_name = source
.get("name")
.and_then(|v| as_string(Some(v)))
.or_else(|| as_string(object.get("plan_name")))
.or_else(|| derive_plan_name(object));
let monthly_price_usd =
get(source, "monthly_price").or_else(|| as_number(object.get("monthly_price")));
let monthly_cap_usd = get(source, "monthly_cap_usd")
.or_else(|| as_number(object.get("monthly_cap_usd")))
.or_else(|| as_number(object.get("billing_cycle_limit")))
.or_else(|| monthly.and_then(|inner| as_number(inner.get("cap"))))
.or_else(|| billing_cap.and_then(|inner| as_number(inner.get("limit"))))
.or_else(|| billing_window.and_then(|w| w.limit));

if plan_name.is_none()
&& monthly_price_usd.is_none()
&& monthly_cap_usd.is_none()
Expand Down Expand Up @@ -683,6 +695,10 @@ fn derive_plan_name(payload: &serde_json::Map<String, serde_json::Value>) -> Opt
}

fn normalize_plan_limits(plan: Option<&PlanInfo>) -> Vec<PlanLimitEntry> {
if plan.and_then(|p| p.plan_name.as_deref()) == Some("Pay-as-you-go") {
return Vec::new();
}

let references = [("Plus", 10.0, 6.0), ("Pro", 20.0, 10.0)];
let mut entries: Vec<PlanLimitEntry> = references
.iter()
Expand Down Expand Up @@ -816,6 +832,44 @@ mod tests {
assert_eq!(daily.reset_label.as_deref(), Some("Possible sync delay"));
}

#[test]
fn uncapped_monthly_payload_is_pay_as_you_go() {
let snapshot = serde_json::json!({
"subscription_usage": {
"subscription": true,
"custom": true,
"monthly_price": 20,
"monthly": {"uncapped": true},
"four_hour": {"usage": 0, "cap": 8.333333333333332, "remaining": 8.333333333333332}
},
"quotas": [{"chute_id": "*", "quota": 5001, "model": "All Models"}],
"quota_usage": {"used": 0, "quota": 5001},
"model_stats": serde_json::Value::Null,
});
let profile = serde_json::json!({"balance": 53.50094759099999});
let summary = summarize_snapshot(&snapshot, &profile);
let plan = summary.plan.expect("plan");
assert_eq!(plan.plan_name.as_deref(), Some("Pay-as-you-go"));
assert_eq!(plan.monthly_price_usd, None);
assert_eq!(plan.monthly_cap_usd, None);
assert_eq!(plan.four_hour_cap_usd, Some(8.333333333333332));
assert_eq!(plan.daily_request_limit, Some(5001.0));
assert!(
summary
.windows
.iter()
.all(|w| w.kind != UsageWindowKind::BillingCycle)
);
assert!(
summary
.windows
.iter()
.any(|w| w.kind == UsageWindowKind::Rolling4h)
);
assert!(summary.plan_limits.is_empty());
assert_eq!(summary.payg_credit_usd, Some(53.50094759099999));
}

#[test]
fn plan_derives_from_price_and_flags() {
let snapshot = serde_json::json!({
Expand Down
2 changes: 1 addition & 1 deletion crates/codegen/chutes-build/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "chutes-build"
version = "2.0.10"
version = "2.0.11"
edition.workspace = true
license = "Apache-2.0"
authors = ["Michael Gasperini (Mikesoft)"]
Expand Down
2 changes: 1 addition & 1 deletion crates/codegen/chutes-pager/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "chutes-pager"
version = "2.0.10"
version = "2.0.11"
edition.workspace = true
license = "Apache-2.0"
authors = ["Chutes Build contributors"]
Expand Down
10 changes: 7 additions & 3 deletions crates/codegen/chutes-pager/docs/user-guide/04-slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ list changes over time, so no model is hardcoded: `/imagine <description>`
picks a currently available image model from the live Chutes catalog, and
`/imagine <model> <description>` uses the model you name (`--model <model>`
is the unambiguous form). Bare `/imagine` lists the image models that are
available right now.
available right now. If no image model is available, it says so explicitly
instead of inventing a model; image generation stays unavailable until Chutes
publishes one.

```
/imagine a golden sunset over a calm ocean with silhouetted palm trees
Expand All @@ -263,7 +265,9 @@ generates source images, and animates them with `generate_media`. As with
`/imagine`, the model is chosen from the live catalog unless you name one:
`/imagine-video <description>` auto-selects, `/imagine-video <model>
<description>` (or `--model <model>`) uses your choice, and bare
`/imagine-video` lists the video models available right now.
`/imagine-video` lists the video models available right now. If no video model
is available, it says so explicitly instead of inventing a model; video
generation stays unavailable until Chutes publishes one.

```
/imagine-video a cat playing piano in a jazz club
Expand Down Expand Up @@ -444,7 +448,7 @@ Unavailable: this build has no account sign-in.

### `/account`

Show the Chutes plan, quotas, usage windows, and PAYG credit for the
Show the Chutes billing mode, quotas, usage windows, and PAYG credit for the
configured API key. Same data as `chutes-build account` (which also offers
`--json` for scripts).

Expand Down
35 changes: 17 additions & 18 deletions crates/codegen/chutes-pager/docs/user-guide/25-chutes-ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,20 @@ stats are resolved by `chute_id` against the live catalogue, so model names are
shown instead of private chute labels. When a saved `default` pool exists, Auto
targets `default:latency`, so the pool configured in the Chutes web app wins
over the client-side inline pool. TEE and PAYG tool-capable rows are pooled
together now that subscriptions are ending.
together now that Chutes bills pay-as-you-go.

Pool writes are not part of the supported surface yet: the Chutes alias endpoint
is eventually consistent and intermittently returns 502. `/routing` and the
plain `routing` command stay read-only.

Some catalog entries have `confidential_compute: false`: they run on an external
provider and require a positive PAYG balance, separately from any subscription
quota. The beta models in this group (Qwen3.8-Max, DeepSeek-V4.1-Flash, GLM-5.3,
Kimi-K2.7-Code, GLM-5.2-Fast, Qwen3.8-2.4T-A95B) are PAYG-only and not TEE. When
that balance is not positive Chutes returns
`402 A positive balance is required for external provider execution`. This is a
PAYG funding condition, not the weekly or monthly subscription limit; top up the
PAYG balance at https://chutes.ai/?_s=usage or use a TEE model. The picker marks
those entries with a `PAYG` notice.
provider and require a positive PAYG balance. The beta models in this group
(Qwen3.8-Max, DeepSeek-V4.1-Flash, GLM-5.3, Kimi-K2.7-Code, GLM-5.2-Fast,
Qwen3.8-2.4T-A95B) are PAYG-only and not TEE. When that balance is not positive
Chutes returns `402 A positive balance is required for external provider
execution`. This is a PAYG funding condition, not the rolling 4-hour or daily
request limit; top up the PAYG balance at https://chutes.ai/?_s=usage or use a
TEE model. The picker marks those entries with a `PAYG` notice.

TEE models run inside hardware enclaves and expose attestation evidence through
`GET https://api.chutes.ai/chutes/{chute_id}/evidence`; do not claim verified
Expand Down Expand Up @@ -158,14 +157,14 @@ the gap and separates documented facts from inference.

## Plan and quota indicator

The status bar shows the account plan plus the rolling four-hour and monthly
percentages when both are available. Its severity follows the highest consumed
active window: green is below 80%, warning color begins at 80%, and the error
color is used at 100% or above. Click the indicator or run `/usage` to inspect
every active window and its reset time.
The status bar shows the account billing mode (for example **Pay-as-you-go**)
plus the rolling four-hour and daily percentages when available. Its severity
follows the highest consumed active window: green is below 80%, warning color
begins at 80%, and the error color is used at 100% or above. Click the indicator
or run `/usage` to inspect every active window and its reset time.

Subscription usage and quota metadata are loaded concurrently. When aggregate
quota usage is not available, Chutes Build queries the documented per-chute
quota endpoints concurrently and combines their used/limit values. The compact
Billing usage and quota metadata are loaded concurrently. When aggregate quota
usage is not available, Chutes Build queries the documented per-chute quota
endpoints concurrently and combines their used/limit values. The compact
indicator and detailed view preserve whether each window is daily, four-hour,
weekly, or monthly.
weekly, or any other window the API returns.
53 changes: 53 additions & 0 deletions crates/codegen/chutes-pager/src/account_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,59 @@ mod tests {
}
}

#[test]
fn human_dashboard_reports_pay_as_you_go_without_monthly_caps() {
let summary = AccountSummary {
windows: vec![
UsageWindow {
id: "four_hour_window".to_string(),
kind: UsageWindowKind::Rolling4h,
label: "4-Hour Window".to_string(),
unit: UsageUnit::Usd,
used: Some(0.0),
limit: Some(8.33),
remaining: Some(8.33),
percent_used: Some(0.0),
reset_label: None,
status: None,
data_source: None,
},
UsageWindow {
id: "quotas-daily".to_string(),
kind: UsageWindowKind::DailyRequests,
label: "Daily Quota".to_string(),
unit: UsageUnit::Requests,
used: Some(0.0),
limit: Some(5001.0),
remaining: Some(5001.0),
percent_used: Some(0.0),
reset_label: None,
status: Some("trusted".to_string()),
data_source: Some("quota-usage".to_string()),
},
],
quotas: Vec::new(),
plan: Some(PlanInfo {
plan_name: Some("Pay-as-you-go".to_string()),
monthly_price_usd: None,
monthly_cap_usd: None,
four_hour_cap_usd: Some(8.33),
daily_request_limit: Some(5001.0),
payg_discount_percent: None,
}),
plan_limits: Vec::new(),
payg_credit_usd: Some(53.5),
};

let text = format_human(&summary);
assert!(text.contains("Plan: Pay-as-you-go"), "{text}");
assert!(!text.contains("/mo"), "{text}");
assert!(!text.contains("monthly cap"), "{text}");
assert!(text.contains("4-hour cap"), "{text}");
assert!(text.contains("daily requests"), "{text}");
assert!(text.contains("Credit: $53.50 PAYG balance"), "{text}");
}

#[test]
fn human_dashboard_degrades_without_data() {
let text = format_human(&AccountSummary {
Expand Down
2 changes: 1 addition & 1 deletion crates/codegen/chutes-shell/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
license = "Apache-2.0"
name = "chutes-shell"
version = "2.0.10"
version = "2.0.11"
edition.workspace = true

[features]
Expand Down
6 changes: 5 additions & 1 deletion crates/codegen/chutes-tools-api/src/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ fn imagine_model_list_instruction(kind: &str) -> String {
"imagine-video"
};
format!(
"Call list_media_models with kind \"{kind}\" and limit 20, then present the available {kind} models as a compact list: slug (or name), id, and a one-line reason to pick it. Do not generate anything yet. Close with the three forms the user can run: `/{command} <description>` lets the tool pick an available model automatically, `/{command} <model> <description>` chooses one of the listed models, and `/{command} --model <model> <description>` is the unambiguous form. Only list models that appear in the tool result; never invent one."
"Call list_media_models with kind \"{kind}\" and limit 20, then present the available {kind} models as a compact list: slug (or name), id, and a one-line reason to pick it. Do not generate anything yet. If the tool result has count 0, tell the user clearly that no {kind} model is currently available and that {kind} generation cannot be performed right now; do not invent a model or claim that generation succeeded. Close with the three forms the user can run: `/{command} <description>` lets the tool pick an available model automatically, `/{command} <model> <description>` chooses one of the listed models, and `/{command} --model <model> <description>` is the unambiguous form. Only list models that appear in the tool result; never invent one."
)
}

Expand Down Expand Up @@ -343,6 +343,8 @@ mod tests {
let text = imagine_instruction("");
assert!(text.contains("list_media_models"));
assert!(!text.contains("generate_media"));
assert!(text.contains("count 0"));
assert!(text.contains("no image model is currently available"));
}

#[test]
Expand Down Expand Up @@ -376,6 +378,8 @@ mod tests {
let text = imagine_video_instruction("");
assert!(text.contains("list_media_models"));
assert!(text.contains("/imagine-video <model> <description>"));
assert!(text.contains("count 0"));
assert!(text.contains("no video model is currently available"));
}

#[test]
Expand Down
Loading
Loading