fix(vscode): validate Ollama quota responses consistently
Share the Ollama request and parser between credential validation and quota refresh so unparseable pages cannot produce successful empty usage. Reject redirects and bound requests with a timeout while preserving both plan formats. Tested with 94 quota tests, VS Code type-check and ESLint, and the extension build. Reviewed dead-code output; existing anti-slop findings remain outside the changed code.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
type OllamaWindow = { usedPercent: number | null; valueLabel?: string };
|
||||
type OllamaFetch = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
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<string, OllamaWindow> = {};
|
||||
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;
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
@@ -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<Response> = 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) {
|
||||
|
||||
@@ -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: '<h1>Monthly usage</h1><p>$25.00 of $100.00</p>', 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<typeof result.usage>['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 ['', '<h1>Monthly usage</h1>', '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<Uint8Array>({
|
||||
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' } });
|
||||
|
||||
|
||||
@@ -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, unknown> | string;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
@@ -1866,77 +1867,14 @@ const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({
|
||||
usageFieldsAreRemaining: true,
|
||||
});
|
||||
|
||||
const parseOllamaSettingsHtml = (html: string) => {
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
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<ProviderResult> => {
|
||||
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<Response>;
|
||||
} = {}): Promise<ProviderResult> => {
|
||||
const cookie = readCookie();
|
||||
|
||||
if (!cookie) {
|
||||
return buildResult({
|
||||
@@ -1949,30 +1887,17 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user