diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index e4b04589..eac37698 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -24,11 +24,43 @@ export async function getIflowUsage(accessToken) { } } +const OLLAMA_LIMIT_WINDOWS = { + session: "Session (5h)", + weekly: "Weekly (7d)", + monthly: "Monthly", +}; + +function addUtcMonths(date, months) { + const total = date.getUTCMonth() + months; + const year = date.getUTCFullYear() + Math.floor(total / 12); + const month = ((total % 12) + 12) % 12; + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return new Date(Date.UTC( + year, month, Math.min(date.getUTCDate(), lastDay), + date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), + )); +} + +// Free plan: "usage resets monthly from the date you signed up" (ollama.com/pricing). +function nextMonthlyResetFromSignup(createdAt, now = new Date()) { + const anchor = new Date(createdAt); + if (Number.isNaN(anchor.getTime())) return null; + const elapsedMonths = (now.getUTCFullYear() - anchor.getUTCFullYear()) * 12 + + (now.getUTCMonth() - anchor.getUTCMonth()); + for (let i = Math.max(0, elapsedMonths); i <= elapsedMonths + 1; i++) { + const candidate = addUtcMonths(anchor, i); + if (candidate > now) return candidate.toISOString(); + } + return null; +} + /** * Ollama Cloud Usage - * GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1 - * ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed. - * POST https://ollama.com/api/me — plan label (fail-open). + * GET https://ollama.com/api/usage — `limits..usage` is a 0..1 ratio + * (1.0 = limit reached). Paid plans report session (5h) + weekly (7d); the + * free plan reports a single monthly window. No reset timestamp exposed; + * the free monthly reset is derived from the account's signup date. + * POST https://ollama.com/api/me — plan label + CreatedAt (fail-open). * Auth: Authorization: Bearer */ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) { @@ -84,14 +116,20 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false }; } - const sessionRaw = limits.session?.usage; - const weeklyRaw = limits.weekly?.usage; - const sessionNum = Number(sessionRaw); - const weeklyNum = Number(weeklyRaw); - const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum); - const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum); + const monthlyResetAt = planRaw.toLowerCase() === "free" && me?.CreatedAt + ? nextMonthlyResetFromSignup(me.CreatedAt) + : null; - if (!hasSession && !hasWeekly) { + const quotas = {}; + for (const [key, label] of Object.entries(OLLAMA_LIMIT_WINDOWS)) { + const raw = limits[key]?.usage; + if (raw === undefined || raw === null) continue; + const ratio = Number(raw); + if (Number.isNaN(ratio)) continue; + quotas[label] = ratioQuota(ratio, key === "monthly" ? monthlyResetAt : null); + } + + if (Object.keys(quotas).length === 0) { return { plan, message: "Ollama Cloud connected. No usage limits reported.", @@ -99,10 +137,6 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions }; } - const quotas = {}; - if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum); - if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum); - return { plan, quotas }; } catch (error) { return { message: `Ollama Cloud error: ${error.message}` }; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 377bb02d..784e6e47 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -632,7 +632,7 @@ export function parseQuotaData(provider, data) { break; case "ollama": - // Session (5h) / Weekly (7d) usage % from ollama.com/api/usage. + // Session (5h) / Weekly (7d) / Monthly usage % from ollama.com/api/usage. // remainingPercentage only — no absolute remaining (UI treats remaining as %). if (data.quotas) { Object.entries(data.quotas).forEach(([name, quota]) => { diff --git a/tests/unit/ollama-usage.test.js b/tests/unit/ollama-usage.test.js index fd8587e3..b3a5b5c2 100644 --- a/tests/unit/ollama-usage.test.js +++ b/tests/unit/ollama-usage.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ proxyAwareFetch: vi.fn(), @@ -44,6 +44,27 @@ const SAMPLE_USAGE = { }, }; +const SAMPLE_FREE_USAGE = { + activity: { + cost: "0.00000", + period: { + type: "last_4_weeks", + starting_at: "2026-08-24T00:00:00Z", + ending_at: "2026-09-18T15:03:00Z", + }, + models: [], + }, + limits: { + monthly: { + usage: 0.021, + models: [ + { name: "gpt-oss:120b", request_count: 6 }, + { name: "gemma4:31b", request_count: 6 }, + ], + }, + }, +}; + const SAMPLE_ME = { Plan: "max", }; @@ -103,6 +124,98 @@ describe("getUsageForProvider(ollama)", () => { expect(meOpts.headers["Content-Length"]).toBe("0"); }); + it("maps the free plan's monthly window", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "free" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Free"); + expect(Object.keys(usage.quotas)).toEqual(["Monthly"]); + expect(usage.quotas["Monthly"]).toMatchObject({ + used: 2, + total: 100, + remainingPercentage: 98, + unlimited: false, + }); + expect(usage.quotas["Monthly"].remaining).toBeUndefined(); + expect(usage.quotas["Monthly"].resetAt).toBeNull(); + }); + + describe("free plan monthly reset from signup date", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + async function monthlyResetAt(createdAt, now) { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "free", CreatedAt: createdAt })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + return usage.quotas["Monthly"].resetAt; + } + + it("uses the signup day of the next month", async () => { + expect(await monthlyResetAt("2025-09-06T22:15:39.871687Z", "2026-09-18T15:03:00Z")) + .toBe("2026-10-06T22:15:39.000Z"); + }); + + it("stays in the current month when the signup day is still ahead", async () => { + expect(await monthlyResetAt("2026-09-18T09:50:49.514335Z", "2026-09-18T15:33:33Z")) + .toBe("2026-10-18T09:50:49.000Z"); + expect(await monthlyResetAt("2025-09-25T10:00:00Z", "2026-09-18T15:33:33Z")) + .toBe("2026-09-25T10:00:00.000Z"); + }); + + it("clamps the signup day to shorter months", async () => { + expect(await monthlyResetAt("2026-01-31T12:00:00Z", "2026-02-10T00:00:00Z")) + .toBe("2026-02-28T12:00:00.000Z"); + }); + + it("skips the reset when the plan is not free", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-18T15:03:00Z")); + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "pro", CreatedAt: "2025-09-06T22:15:39Z" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + expect(usage.quotas["Monthly"].resetAt).toBeNull(); + }); + }); + + it("reports no limits when no known window is present", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse({ activity: {}, limits: {} })) + .mockResolvedValueOnce(jsonResponse({ Plan: "free" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + + expect(usage.message).toMatch(/no usage limits/i); + expect(usage.quotas).toEqual({}); + }); + it("surfaces invalid key message on 401", async () => { proxyAwareFetch.mockResolvedValueOnce( jsonResponse({ error: "unauthorized" }, 401),