diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 2116bf11..0e9c3a3e 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -83,6 +83,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited. - Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider. + - `ollamaQuota.ts` owns the Ollama settings request and parser shared by credential validation and quota refresh. Both reject redirects, failed HTTP responses, and pages without parsed windows, with a 15-second request timeout. Validation finishes before the bridge writes a replacement cookie. Monthly dollar quotas and legacy session/weekly/premium quotas remain supported; zero extra-credit balances are omitted. - `opencode-upgrade-runtime.ts` - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. diff --git a/packages/vscode/src/ollamaQuota.ts b/packages/vscode/src/ollamaQuota.ts new file mode 100644 index 00000000..3814a897 --- /dev/null +++ b/packages/vscode/src/ollamaQuota.ts @@ -0,0 +1,64 @@ +type OllamaWindow = { usedPercent: number | null; valueLabel?: string }; +type OllamaFetch = (url: string, init: RequestInit) => Promise; + +export const fetchOllamaUsage = async (cookie: string, fetchImpl: OllamaFetch = fetch) => { + const response = await fetchImpl('https://ollama.com/settings', { + method: 'GET', + headers: { + Cookie: cookie, + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + redirect: 'manual', + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error('Ollama Cloud authentication failed'); + + const html = await response.text(); + const windows: Record = {}; + for (const [key, pattern] of [ + ['session', /Session\s+usage[^0-9]*([0-9.]+)%/i], + ['weekly', /Weekly\s+usage[^0-9]*([0-9.]+)%/i], + ] as const) { + const match = html.match(pattern); + if (!match) continue; + const usedPercent = Number(match[1]); + if (Number.isFinite(usedPercent)) { + windows[key] = { usedPercent }; + } + } + + const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i); + if (premium) { + const used = Number(premium[1]); + const total = Number(premium[2]); + if (Number.isFinite(used) && Number.isFinite(total)) { + windows.premium = { + usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null, + valueLabel: `${used} / ${total}`, + }; + } + } + + const monthly = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i); + if (monthly) { + const used = Number(monthly[1].replace(/,/g, '')); + const total = Number(monthly[2].replace(/,/g, '')); + if (Number.isFinite(used) && Number.isFinite(total)) { + windows.monthly = { + usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null, + valueLabel: `$${monthly[1]} / $${monthly[2]}`, + }; + } + } + + // Anchor on the balance label, not nearby purchase or auto-reload amounts. + const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i); + if (balanceMatch) { + const balance = Number(balanceMatch[1].replace(/,/g, '')); + if (Number.isFinite(balance) && balance > 0) { + windows.credits_balance = { usedPercent: null, valueLabel: `$${balanceMatch[1]}` }; + } + } + if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index 4133a96c..a4c17214 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fetchExeDevUsage } from './exeDevQuota'; +import { fetchOllamaUsage } from './ollamaQuota'; export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor'; export type ManagedCredential = Record; @@ -53,13 +54,10 @@ export const importCursorCredential = () => { return credential; }; -export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => { +export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential, fetchImpl: (url: string, init: RequestInit) => Promise = fetch) => { if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken); if (provider === 'ollama-cloud') { - 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]+|Monthly\s+usage/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed'); + await fetchOllamaUsage(credential.cookie, fetchImpl); } if (provider === 'cursor') { if (!credential.accessToken && credential.refreshToken) { diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index ba9cb1d8..784d5bc5 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -26,7 +26,8 @@ const AUTH = JSON.stringify({ ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; -import { fetchHyperQuota, fetchQuotaForProvider } from './quotaProviders'; +import { fetchHyperQuota, fetchOllamaCloudQuota, fetchQuotaForProvider } from './quotaProviders'; +import { validateCredential } from './quotaCredentials'; type MockResponseInit = { ok?: boolean; status?: number }; @@ -723,6 +724,104 @@ describe('DeepSeek quota provider (VS Code parity)', () => { }); }); +describe('Ollama Cloud quota validation and refresh', () => { + const credential = { cookie: 'test-ollama-cookie' }; + const readCookie = () => credential.cookie; + + for (const { html, expected } of [ + { html: '

Monthly usage

$25.00 of $100.00

', expected: { monthly: { usedPercent: 25, valueLabel: '$25.00 / $100.00' } } }, + { html: 'Monthly usage $1,250.00 of $2,500.00', expected: { monthly: { usedPercent: 50, valueLabel: '$1,250.00 / $2,500.00' } } }, + { html: 'Session usage 12% Weekly usage 34% Premium 2 / 10', expected: { session: { usedPercent: 12 }, weekly: { usedPercent: 34 }, premium: { usedPercent: 20, valueLabel: '2 / 10' } } }, + { html: 'Monthly usage $0 of $100 Balance remaining $5.25 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' }, credits_balance: { usedPercent: null, valueLabel: '$5.25' } } }, + { html: 'Monthly usage $0 of $100 Balance remaining $0.00 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' } } }, + { html: 'Monthly usage $125 of $100 Add $5', expected: { monthly: { usedPercent: 100, valueLabel: '$125 / $100' } } }, + ]) { + test(`accepts and displays ${html}`, async () => { + let requests = 0; + const fetchImpl = async (url: string, init: RequestInit) => { + requests += 1; + assert.equal(url, 'https://ollama.com/settings'); + assert.equal(init.redirect, 'manual'); + assert.equal(init.method, 'GET'); + assert.equal(new Headers(init.headers).get('Cookie'), credential.cookie); + assert.ok(init.signal instanceof AbortSignal); + return new Response(html); + }; + await validateCredential('ollama-cloud', credential, fetchImpl); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(requests, 2); + assert.equal(result.ok, true); + assert.ok(result.usage); + assert.deepEqual(Object.keys(result.usage.windows), Object.keys(expected)); + for (const [key, expectedWindow] of Object.entries(expected)) { + const window: NonNullable['windows'][string] = result.usage.windows[key]; + assert.ok(window); + assert.equal(window.usedPercent, expectedWindow.usedPercent); + if ('valueLabel' in expectedWindow) assert.equal(window.valueLabel, expectedWindow.valueLabel); + assert.equal(window.resetAt, null); + } + assert.equal(JSON.stringify(result).includes(credential.cookie), false); + }); + } + + for (const html of ['', '

Monthly usage

', 'Session usage', 'Session usage 1.2.3%', 'Weekly usage 1.2.3%', 'Add $5', 'Monthly usage $1.2.3 of $100', 'Balance remaining $1.2.3']) { + test(`rejects unparseable HTML ${JSON.stringify(html)} in both consumers`, async () => { + const fetchImpl = async () => new Response(html); + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /usage data could not be parsed/); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'Ollama Cloud usage data could not be parsed'); + }); + } + + for (const status of [302, 307, 401, 403, 429, 500]) { + test(`rejects HTTP ${status} in both consumers`, async () => { + const fetchImpl = async () => new Response('Monthly usage $25 of $100', { status }); + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /authentication failed/); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.usage, null); + assert.equal(result.error, 'Ollama Cloud authentication failed'); + }); + } + + for (const failure of [new DOMException('Request timed out', 'TimeoutError'), new Error('Network unavailable')]) { + test(`reports ${failure.message} in both consumers`, async () => { + const fetchImpl = async () => { throw failure; }; + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.usage, null); + assert.equal(result.error, failure.message); + }); + } + + test('does not request usage without a cookie', async () => { + const result = await fetchOllamaCloudQuota({ readCookie: () => undefined, fetchImpl: async () => { assert.fail('Unexpected request'); } }); + assert.equal(result.configured, false); + assert.equal(result.ok, false); + }); + + test('reports response body failures in both consumers', async () => { + const failure = new Error('Response body interrupted'); + const fetchImpl = async () => new Response(new ReadableStream({ + start(controller) { + controller.error(failure); + }, + })); + + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, failure.message); + assert.deepEqual(credential, { cookie: 'test-ollama-cookie' }); + }); +}); + describe('Charm Hyper quota provider (VS Code parity)', () => { const readAuth = () => ({ hyper: { key: 'test-token' } }); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 15a37fc0..962c7103 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -6,6 +6,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; import { fetchExeDevUsage } from './exeDevQuota'; +import { fetchOllamaUsage } from './ollamaQuota'; type AuthEntry = Record | string; type AuthFile = Record; @@ -1866,77 +1867,14 @@ const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({ usageFieldsAreRemaining: true, }); -const parseOllamaSettingsHtml = (html: string) => { - const windows: Record = {}; - const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i); - if (sessionMatch) { - windows.session = toUsageWindow({ - usedPercent: toNumber(sessionMatch[1]), - windowSeconds: null, - resetAt: null, - }); - } - - const weeklyMatch = html.match(/Weekly\s+usage[^0-9]*([0-9.]+)%/i); - if (weeklyMatch) { - windows.weekly = toUsageWindow({ - usedPercent: toNumber(weeklyMatch[1]), - windowSeconds: null, - resetAt: null, - }); - } - - const premiumMatch = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i); - if (premiumMatch) { - const used = toNumber(premiumMatch[1]); - const total = toNumber(premiumMatch[2]); - const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null; - windows.premium = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt: null, - 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; -}; - -const fetchOllamaCloudQuota = async (): Promise => { - const cookie = readCredential('ollama-cloud')?.cookie; +export const fetchOllamaCloudQuota = async ({ + readCookie = () => readCredential('ollama-cloud')?.cookie, + fetchImpl = fetch, +}: { + readCookie?: () => string | undefined; + fetchImpl?: (url: string, init: RequestInit) => Promise; +} = {}): Promise => { + const cookie = readCookie(); if (!cookie) { return buildResult({ @@ -1949,30 +1887,17 @@ const fetchOllamaCloudQuota = async (): Promise => { } try { - const response = await fetch('https://ollama.com/settings', { - method: 'GET', - headers: { - Cookie: cookie, - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - }, - }); - - if (!response.ok) { - return buildResult({ - providerId: 'ollama-cloud', - providerName: 'Ollama Cloud', - ok: false, - configured: true, - error: `API error: ${response.status}`, - }); - } + const parsed = await fetchOllamaUsage(cookie, fetchImpl); + const windows = Object.fromEntries(Object.entries(parsed).map(([key, value]) => [ + key, toUsageWindow({ ...value, windowSeconds: null, resetAt: null }), + ])); return buildResult({ providerId: 'ollama-cloud', providerName: 'Ollama Cloud', ok: true, configured: true, - usage: { windows: parseOllamaSettingsHtml(await response.text()) }, + usage: { windows }, }); } catch (error) { return buildResult({