commit bb6838b9c104b4902178e2dc8a89be358400c386 from: mtmn date: Mon Sep 7 18:22:21 2026 UTC fix ollama cloud usage api commit - 2b7e8021abe55fdf0bb84b568215ea14f6185f98 commit + bb6838b9c104b4902178e2dc8a89be358400c386 blob - 1855a11ac15917996b9fcb9e25fe772d44430c1b blob + 10c9a0070028473452cdc4b04d9337df264db448 --- README.md +++ README.md @@ -293,9 +293,11 @@ is funds ready to spend, and a positive one is money o 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 @@ -67,9 +67,11 @@ all providers. *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 @@ -15,9 +15,11 @@ const WEEKLY_OFFSET: TimeDelta = TimeDelta::days(4); #[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, #[serde(default)] - usage: f64, - #[serde(default)] models: Vec, } @@ -37,6 +39,8 @@ struct Activity { cost: String, #[serde(default)] period: Period, + #[serde(default)] + models: Vec, } #[derive(Debug, Default, Deserialize)] @@ -45,6 +49,8 @@ struct Limits { session: Limit, #[serde(default)] weekly: Limit, + #[serde(default)] + monthly: Limit, } #[derive(Debug, Default, Deserialize)] @@ -57,10 +63,15 @@ struct Usage { /// 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 /// @@ -83,7 +94,54 @@ pub fn view(body: &[u8]) -> Result { fn build(usage: &Usage, now: DateTime) -> 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!( @@ -92,30 +150,8 @@ fn build(usage: &Usage, now: DateTime) -> View { )), 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, } } @@ -195,6 +231,90 @@ mod test { } #[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 @@ -634,6 +634,42 @@ fn ollama_quota_reports_session_and_weekly_windows() { } #[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()