From b238cd7fbc66a443b690743f34af2dc6efe7be1e Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Mon, 7 Sep 2026 18:25:52 +0200 Subject: [PATCH] fix: parse ollama cloud cost-based usage windows and credits balance (#3381) Ollama Cloud's /settings page switched shapes for cost-based plans: parse the 'Monthly usage $X of $Y used' row as a monthly window with a symmetric '$X / $Y' money label that reads correctly in both used/remaining display modes, and surface the Extra usage credits balance as a balance-only credits_balance window matching the Codex/DeepSeek credits treatment ('Credits Balance' in the UI), omitted when the balance is $0. The VS Code credential validation accepts the new page shape and the Settings cookie placeholder shows the two-cookie format. Web and VS Code parsers kept in sync per quota DOCUMENTATION.md. --- .../sections/usage/QuotaCredentials.tsx | 2 +- packages/vscode/src/quotaCredentials.ts | 2 +- packages/vscode/src/quotaProviders.ts | 33 ++++++++++++++++++ .../web/server/lib/quota/DOCUMENTATION.md | 6 +++- .../lib/quota/providers/ollama-cloud.js | 31 +++++++++++++++++ .../lib/quota/providers/ollama-cloud.test.js | 34 ++++++++++++++++++- 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx index 840cc905..a693f027 100644 --- a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx +++ b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx @@ -41,7 +41,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:

{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}

{EXE_DEV_TOKEN_COMMAND} } - {providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')} + {providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'aid=...; __Secure-session=...')} {providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))} {providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))} {providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))} diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index b7f5968d..4133a96c 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -59,7 +59,7 @@ export const validateCredential = async (provider: ManagedProvider, credential: const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed'); const html = await response.text(); - if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed'); + if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+|Monthly\s+usage/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed'); } if (provider === 'cursor') { if (!credential.accessToken && credential.refreshToken) { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 8e282b6b..15a37fc0 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1899,6 +1899,39 @@ const parseOllamaSettingsHtml = (html: string) => { }); } + // Cost-based plans render "Monthly usage" with a dollar amount instead of + // session/weekly/premium windows; support both page shapes. + const monthlyMatch = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i); + if (monthlyMatch) { + const used = toNumber(monthlyMatch[1].replace(/,/g, '')); + const total = toNumber(monthlyMatch[2].replace(/,/g, '')); + const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null; + windows.monthly = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: null, + valueLabel: `$${monthlyMatch[1]} / $${monthlyMatch[2]}`, + }); + } + + // "Extra usage" credits block (visible when credits/auto-reload is enabled): + // a balance, not a percent. Anchor on "Balance remaining" — nearby "Add $5" + // and auto-reload copy also contain dollar amounts. Surfaced with the + // credits_balance key and OpenAI-style plain money label (the UI renders + // it as "Credits Balance"); a $0 balance is omitted rather than shown. + const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i); + if (balanceMatch) { + const balance = toNumber(balanceMatch[1].replace(/,/g, '')); + if (balance !== 0) { + windows.credits_balance = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${balanceMatch[1]}`, + }); + } + } + return windows; }; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 867d7132..fdfe118f 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -35,7 +35,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide | `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` | | `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` | | `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` | -| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` | +| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie pasted into Settings (`aid=...; __Secure-session=...` from `ollama.com`), stored under `~/.config/openchamber/quota/` | | `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` | | `opencode-go` | OpenCode Go | `providers/opencode-go.js` | `opencode-go` API key from OpenCode `auth.json` | | `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) | @@ -105,6 +105,10 @@ Web and VS Code accept finite numeric balances and non-empty numeric strings. Mi The provider computes `usedPercent` from whichever of `used`/`remaining` is present (`used` takes precedence when both exist) rather than assuming one field name. Both `packages/web/server/lib/quota/providers/kimi.js` and `packages/vscode/src/quotaProviders.ts` (`fetchKimiQuota`) must stay in sync — the VS Code extension duplicates this parsing logic rather than importing it. +## Ollama Cloud settings-page shapes + +Ollama Cloud authentication uses two cookies (`aid` and `__Secure-session`) pasted together as one single-line Cookie header value. Ollama serves two different `/settings` page shapes and `parseOllamaSettingsHtml` supports both: session/weekly/premium-interaction windows (percent-based plans) and a `monthly` window derived from "Monthly usage: $X of $Y used" (cost-based plans) with a symmetric `$X / $Y` money `valueLabel` that reads correctly in both used/remaining display modes. The "Extra usage" credits block is surfaced as a balance-only `credits_balance` window with a plain money `valueLabel` when present, matching the Codex/DeepSeek credits treatment ("Credits Balance" in the UI); a $0 balance is omitted instead of showing an empty credits row. Keep `packages/web/server/lib/quota/providers/ollama-cloud.js` and `packages/vscode/src/quotaProviders.ts` (`parseOllamaSettingsHtml`) in sync — the VS Code extension duplicates this parsing logic rather than importing it. + ## GitHub Copilot quota semantics GitHub Copilot usage exposes only the `premium_interactions` snapshot as the diff --git a/packages/web/server/lib/quota/providers/ollama-cloud.js b/packages/web/server/lib/quota/providers/ollama-cloud.js index 1ea99691..30c5ac8f 100644 --- a/packages/web/server/lib/quota/providers/ollama-cloud.js +++ b/packages/web/server/lib/quota/providers/ollama-cloud.js @@ -35,6 +35,37 @@ export const parseOllamaSettingsHtml = (html) => { valueLabel: `${used ?? 0} / ${total ?? 0}` }); } + // Cost-based plans render "Monthly usage" with a dollar amount instead of + // session/weekly/premium windows; support both page shapes. + const monthlyMatch = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i); + if (monthlyMatch) { + const used = toNumber(monthlyMatch[1].replace(/,/g, '')); + const total = toNumber(monthlyMatch[2].replace(/,/g, '')); + const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null; + windows.monthly = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: null, + valueLabel: `$${monthlyMatch[1]} / $${monthlyMatch[2]}` + }); + } + // "Extra usage" credits block (visible when credits/auto-reload is enabled): + // a balance, not a percent. Anchor on "Balance remaining" — nearby "Add $5" + // and auto-reload copy also contain dollar amounts. Surfaced with the + // credits_balance key and OpenAI-style plain money label (the UI renders + // it as "Credits Balance"); a $0 balance is omitted rather than shown. + const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i); + if (balanceMatch) { + const balance = toNumber(balanceMatch[1].replace(/,/g, '')); + if (balance !== 0) { + windows.credits_balance = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${balanceMatch[1]}` + }); + } + } return windows; }; diff --git a/packages/web/server/lib/quota/providers/ollama-cloud.test.js b/packages/web/server/lib/quota/providers/ollama-cloud.test.js index 8cf5bf7c..7cc88974 100644 --- a/packages/web/server/lib/quota/providers/ollama-cloud.test.js +++ b/packages/web/server/lib/quota/providers/ollama-cloud.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { fetchOllamaCloudUsage } from './ollama-cloud.js'; +import { parseOllamaSettingsHtml, fetchOllamaCloudUsage } from './ollama-cloud.js'; describe('Ollama Cloud quota provider', () => { it('rejects redirects without forwarding credentials', async () => { @@ -9,4 +9,36 @@ describe('Ollama Cloud quota provider', () => { it('rejects successful pages without usage data', async () => { await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response(''))).rejects.toThrow('could not be parsed'); }); + + it('parses session/weekly/premium windows', () => { + const windows = parseOllamaSettingsHtml('

Session usage: 42%

Weekly usage: 7%

Premium 120 / 1000

'); + expect(windows.session.usedPercent).toBe(42); + expect(windows.weekly.usedPercent).toBe(7); + expect(windows.premium.usedPercent).toBe(12); + expect(windows.premium.valueLabel).toBe('120 / 1000'); + }); + + it('parses the cost-based monthly usage shape', () => { + const html = 'Monthly usage\n $4.39 of $60 used'; + const windows = parseOllamaSettingsHtml(html); + expect(windows.monthly.usedPercent).toBeCloseTo(7.3, 1); + expect(windows.monthly.valueLabel).toBe('$4.39 / $60'); + expect(windows.monthly.remainingPercent).toBeCloseTo(92.7, 1); + }); + + it('parses the extra-usage credits balance', () => { + const html = '
Balance remaining$12.50
'; + const windows = parseOllamaSettingsHtml(html); + expect(windows.credits_balance.usedPercent).toBeNull(); + expect(windows.credits_balance.valueLabel).toBe('$12.50'); + }); + + it('omits the credits balance when it is zero', () => { + const html = '
Balance remaining$0.00
'; + expect(parseOllamaSettingsHtml(html).credits_balance).toBeUndefined(); + }); + + it('ignores nearby dollar amounts that are not the balance', () => { + expect(parseOllamaSettingsHtml('

Add $5 to your balance when it hits $0

').credits_balance).toBeUndefined(); + }); });