commit - 2b7e8021abe55fdf0bb84b568215ea14f6185f98
commit + bb6838b9c104b4902178e2dc8a89be358400c386
blob - 1855a11ac15917996b9fcb9e25fe772d44430c1b
blob + 10c9a0070028473452cdc4b04d9337df264db448
--- README.md
+++ README.md
The Ollama provider reports Ollama Cloud usage. It takes the key from
`$OLLAMA_API_KEY` and defaults to `https://ollama.com`. You can override this
-with `$OLLAMA_API_ENDPOINT`. alpaca prints the 5 hour and weekly usage windows
-with locally computed reset boundaries, a table of per-model request counts
-for each window, and any extra spend the account has accrued.
+with `$OLLAMA_API_ENDPOINT`. It shows the monthly usage pool as the share of
+the plan's credits used, with per-model request counts and any extra spend
+beyond the plan. The endpoint reports no reset time, so no reset is shown.
+Accounts on the old billing get the 5 hour and weekly windows instead, with
+locally computed reset boundaries.
The Codex provider reports your ChatGPT plan usage, not organization spend.
alpaca reads the OAuth token the Codex CLI stores in `$CODEX_HOME/auth.json`,
blob - bf801bce530c8d8bd207c669acc80f6755d06c39
blob + 3054cefdd7e70d720bfe12a8f5da5d42dfc7672f
--- man/alpaca-quota.1.scd
+++ man/alpaca-quota.1.scd
*ollama*
Ollama Cloud. It takes the key from *OLLAMA_API_KEY*. The base URL is
*https://ollama.com*, or *$OLLAMA_API_ENDPOINT* when set. It prints the
- 5 hour and weekly usage windows with locally computed reset boundaries,
- a table of per-model request counts for each window, and any extra spend
- the account has accrued.
+ monthly usage pool as the share of the plan's credits used, a table of
+ per-model request counts, and any extra spend beyond the plan. It shows
+ no reset, because the endpoint reports no reset time. Accounts on the
+ old billing get the 5 hour and weekly windows instead, with locally
+ computed reset boundaries.
The *anthropic* endpoint is not a documented public API, so it may change
without notice. It expects the short-lived OAuth token the Claude Code CLI
blob - 3d408a6d306c759b7b2d2e5deb9df9661db0ce78
blob + e133bb147b6c894ab6b24ff1746faee228cd1130
--- src/usage/ollama.rs
+++ src/usage/ollama.rs
#[derive(Debug, Default, Deserialize)]
struct Limit {
+ /// Used share of the window. The new billing only reports the monthly
+ /// pool, so a window is drawn for the limits the response actually
+ /// carries, and none are defaulted.
+ usage: Option<f64>,
#[serde(default)]
- usage: f64,
- #[serde(default)]
models: Vec<ModelCount>,
}
cost: String,
#[serde(default)]
period: Period,
+ #[serde(default)]
+ models: Vec<ModelCount>,
}
#[derive(Debug, Default, Deserialize)]
session: Limit,
#[serde(default)]
weekly: Limit,
+ #[serde(default)]
+ monthly: Limit,
}
#[derive(Debug, Default, Deserialize)]
/// Normalise the Ollama Cloud usage response
///
-/// Ollama reports no reset time, so the windows are computed locally. Session
-/// resets align to UTC multiples of 5 hours from the epoch. Weekly resets are
-/// offset by 4 days so all accounts share the same boundary. This matches the
-/// formula in ollama/ollama issue #12532.
+/// Since the transparent pricing of August 2026, the limits carry one monthly
+/// pool, consumed per token against the credits a plan includes. It reports
+/// no reset time: the pool renews on the day the plan started, which the
+/// endpoint does not expose. Accounts still on the old billing instead get
+/// session and weekly windows, whose resets are computed locally because
+/// Ollama reports no reset time there either: session resets align to UTC
+/// multiples of 5 hours from the epoch, and weekly resets are offset by 4
+/// days so all accounts share the same boundary. This matches the formula in
+/// ollama/ollama issue #12532.
///
/// # Errors
///
fn build(usage: &Usage, now: DateTime<Utc>) -> View {
let period = &usage.activity.period;
+ let limits = &usage.limits;
+ let mut windows = Vec::new();
+ let mut tables = Vec::new();
+
+ // Only the windows the response carries are drawn. Under the new billing
+ // the monthly pool has no reported reset: it renews on the day the plan
+ // started, which the endpoint does not expose.
+ if let Some(used) = limits.session.usage {
+ windows.push(Window::new(
+ "session",
+ used,
+ Some(next_boundary(now, SESSION, TimeDelta::zero())),
+ ));
+ tables.push(Table {
+ heading: "session models".to_string(),
+ unit: "reqs",
+ rows: sorted(&limits.session.models),
+ });
+ }
+ if let Some(used) = limits.weekly.usage {
+ windows.push(Window::new(
+ "weekly",
+ used,
+ Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)),
+ ));
+ tables.push(Table {
+ heading: "weekly models".to_string(),
+ unit: "reqs",
+ rows: sorted(&limits.weekly.models),
+ });
+ }
+ if let Some(used) = limits.monthly.usage {
+ windows.push(Window::new("monthly", used, None));
+ tables.push(Table {
+ heading: "monthly models".to_string(),
+ unit: "reqs",
+ rows: sorted(&limits.monthly.models),
+ });
+ }
+ if !usage.activity.models.is_empty() {
+ tables.push(Table {
+ heading: "activity models".to_string(),
+ unit: "reqs",
+ rows: sorted(&usage.activity.models),
+ });
+ }
+
View {
title: "ollama cloud usage".to_string(),
subtitle: Some(format!(
)),
note: (!usage.activity.cost.is_empty())
.then(|| format!("extra {} USD", usage.activity.cost)),
- windows: vec![
- Window::new(
- "session",
- usage.limits.session.usage,
- Some(next_boundary(now, SESSION, TimeDelta::zero())),
- ),
- Window::new(
- "weekly",
- usage.limits.weekly.usage,
- Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)),
- ),
- ],
- tables: vec![
- Table {
- heading: "session models".to_string(),
- unit: "reqs",
- rows: sorted(&usage.limits.session.models),
- },
- Table {
- heading: "weekly models".to_string(),
- unit: "reqs",
- rows: sorted(&usage.limits.weekly.models),
- },
- ],
+ windows,
+ tables,
}
}
}
#[test]
+ fn reads_the_monthly_pool_of_the_new_billing() -> Result<()> {
+ let view = view(
+ br#"{
+ "activity": {
+ "cost": "0.00000",
+ "period": {
+ "type": "last_4_weeks",
+ "starting_at": "2026-08-17T00:00:00Z",
+ "ending_at": "2026-09-07T18:18:40.065660066Z"
+ },
+ "models": []
+ },
+ "limits": {
+ "monthly": {
+ "usage": 0.876,
+ "models": [
+ {"name": "kimi-k3", "request_count": 329},
+ {"name": "web search", "request_count": 4}
+ ]
+ }
+ }
+ }"#,
+ )?;
+
+ assert_eq!(view.windows.len(), 1);
+ assert_eq!(view.windows[0].name, "monthly");
+ assert!((view.windows[0].used - 0.876).abs() < f64::EPSILON);
+ assert_eq!(
+ view.windows[0].resets_in_secs, None,
+ "the monthly pool renews on the plan start day, which is not reported"
+ );
+ assert_eq!(view.tables.len(), 1);
+ assert_eq!(view.tables[0].heading, "monthly models");
+ assert_eq!(
+ view.tables[0].rows,
+ vec![
+ ModelCount {
+ name: "kimi-k3".to_string(),
+ requests: 329
+ },
+ ModelCount {
+ name: "web search".to_string(),
+ requests: 4
+ }
+ ],
+ "models are sorted busiest first"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn omits_windows_the_response_does_not_report() -> Result<()> {
+ let view = view(
+ br#"{
+ "activity": {"cost": "0.00000", "period": {"type": "last_4_weeks"}},
+ "limits": {"session": {"models": []}}
+ }"#,
+ )?;
+
+ assert!(view.windows.is_empty(), "no bar without a usage share");
+ assert!(view.tables.is_empty(), "no table without a window");
+ Ok(())
+ }
+
+ #[test]
+ fn reads_activity_models_when_the_window_has_none() -> Result<()> {
+ let view = view(
+ br#"{
+ "activity": {
+ "cost": "0.00000",
+ "period": {"type": "last_4_weeks"},
+ "models": [{"name": "kimi-k3", "request_count": 329}]
+ },
+ "limits": {"monthly": {"usage": 0.876, "models": []}}
+ }"#,
+ )?;
+
+ assert_eq!(view.tables[0].heading, "monthly models");
+ assert_eq!(view.tables[1].heading, "activity models");
+ assert_eq!(view.tables[1].rows[0].name, "kimi-k3");
+ Ok(())
+ }
+
+ #[test]
fn rejects_non_usage_bodies() {
assert!(view(b"not json").is_err());
assert!(view(b"{}").is_err());
blob - 1a4fb19f07f3acdda32348b6b532af40fef88430
blob + 409a19f4c2734c1d40d110e90edd994f97125775
--- tests/quota.rs
+++ tests/quota.rs
}
#[test]
+fn ollama_quota_reports_the_monthly_pool_of_the_new_billing() {
+ let mut server = mockito::Server::new();
+ let mock = server
+ .mock("GET", "/api/usage")
+ .match_header("authorization", "KEY")
+ .with_body(
+ r#"{"activity":{"cost":"0.00000","period":{"type":"last_4_weeks","starting_at":"2026-08-17T00:00:00Z","ending_at":"2026-09-07T18:18:40.065660066Z"}},
+ "limits":{"monthly":{"usage":0.876,"models":[{"name":"kimi-k3","request_count":329}]}}}"#,
+ )
+ .create();
+
+ offline(alpaca().args([
+ "quota",
+ "-p",
+ "ollama",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("ollama cloud usage")
+ .and(predicate::str::contains("monthly"))
+ .and(predicate::str::contains("87.6%"))
+ .and(predicate::str::contains("monthly models"))
+ .and(predicate::str::contains("kimi-k3"))
+ .and(predicate::str::contains("extra 0.00000 USD"))
+ .and(predicate::str::contains("resets in").not()),
+ );
+
+ mock.assert();
+}
+
+#[test]
fn ollama_quota_without_credentials_fails() {
offline(alpaca().args(["quota", "-p", "ollama"]))
.assert()