From 529bb1edf9c3df9038ae5b9f4d9f8794e388e648 Mon Sep 17 00:00:00 2001 From: "gabriele.palaj" Date: Sun, 10 May 2026 12:00:59 +0200 Subject: [PATCH 1/5] feat: add Crof.AI plugin with per-model token usage and plan tracking Adds a new Crof.AI plugin that displays: - Usage progress bar (via usage_api + pricing_api) - Credit balance - Per-model token breakdown (via user-api/usage with optional session key) The plugin reads CROF_AI_API_KEY (required) and optionally CROF_AI_SESSION_KEY for plan details and model-level usage. --- README.md | 1 + docs/providers/crofai.md | 56 +++ plugins/crofai/icon.svg | 5 + plugins/crofai/plugin.js | 257 ++++++++++++++ plugins/crofai/plugin.json | 22 ++ plugins/crofai/plugin.test.js | 452 ++++++++++++++++++++++++ src-tauri/src/plugin_engine/host_api.rs | 4 +- 7 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 docs/providers/crofai.md create mode 100644 plugins/crofai/icon.svg create mode 100644 plugins/crofai/plugin.js create mode 100644 plugins/crofai/plugin.json create mode 100644 plugins/crofai/plugin.test.js diff --git a/README.md b/README.md index d992761a..d68c43b7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits - [**Antigravity**](docs/providers/antigravity.md) / all models - [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage) +- [**Crof.AI**](docs/providers/crofai.md) / API key, requests, credits, per-model tokens - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions - [**Cursor**](docs/providers/cursor.md) / credits, total usage, auto usage, API usage, on-demand, CLI auth diff --git a/docs/providers/crofai.md b/docs/providers/crofai.md new file mode 100644 index 00000000..b0b1d8d6 --- /dev/null +++ b/docs/providers/crofai.md @@ -0,0 +1,56 @@ +# Crof.AI + +> Uses the Crof.AI API to display usage, credits, plan info, and per-model token breakdown. + +## Overview + +- **Source of truth:** `https://crof.ai/usage_api/`, `https://crof.ai/pricing_api`, `https://crof.ai/user-api/usage` +- **Auth:** API key (required) + session key (optional, for plan + model breakdown) +- **Provider ID:** `crofai` +- **Usage scope:** requests, credits, plan limits, per-model token usage + +## Setup + +### Required: API key + +Open [crof.ai/usage_api/](https://crof.ai/usage_api/) while logged in. Copy your API key, then add to your shell config (`~/.zshrc`): + +```sh +export CROF_AI_API_KEY="your-api-key-here" +``` + +Then `source ~/.zshrc` and restart OpenUsage. + +### Optional: Session key + +For progress bar (max requests from pricing) and per-model token breakdown, also set: + +```sh +export CROF_AI_SESSION_KEY="your-session-cookie-value" +``` + +Get it from [crof.ai](https://crof.ai) > DevTools > Application > Cookies > copy the `session` value. + +Or save it to `{appDataDir}/plugins_data/crofai/session-key` (env var takes priority). + +## Data Sources + +| Endpoint | Auth | Purpose | +|---|---|---| +| `GET /usage_api/` | `Authorization: Bearer ` | Requests + credits (required) | +| `GET /pricing_api` | `Cookie: session=` | Plan name + max requests (optional) | +| `GET /user-api/usage` | `Cookie: session=` | Per-model token breakdown (optional) | + +## Display + +- **Status badge:** Connected (green) when API responds +- **Usage progress bar:** Used requests / plan max (shown when both API key + session key available) +- **Credits:** Dollar-formatted credit balance +- **Total tokens:** Sum of all models' tokens (shown only with session key) +- **Top models:** Top 5 models sorted by token usage (shown only with session key) + +## Failure Behavior + +- **Missing API key:** Error asking to set `CROF_AI_API_KEY` +- **401/403 on usage_api:** API key invalid or expired +- **Bad session key:** Only session-backed features drop out, API key data still shows diff --git a/plugins/crofai/icon.svg b/plugins/crofai/icon.svg new file mode 100644 index 00000000..e8fe63f9 --- /dev/null +++ b/plugins/crofai/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js new file mode 100644 index 00000000..2b91dd3d --- /dev/null +++ b/plugins/crofai/plugin.js @@ -0,0 +1,257 @@ +(function () { + var PROVIDER_ID = "crofai"; + var USAGE_API_URL = "https://crof.ai/usage_api/"; + var USER_USAGE_URL = "https://crof.ai/user-api/usage"; + var PRICING_URL = "https://crof.ai/pricing_api"; + var TIMEOUT_MS = 10000; + var MAX_DISPLAY_MODELS = 5; + + var PLAN_NAMES = { + hobby: "Hobby", + pro: "Pro", + int: "Intermediate", + scale: "Scale", + max: "Max", + }; + var FALLBACK_MAX_REQUESTS = 15000; + + function readApiKey(ctx) { + var raw = ctx.host.env.get("CROF_AI_API_KEY"); + if (typeof raw === "string" && raw.trim()) { + return raw.trim(); + } + throw ( + "Crof.AI not configured. Set CROF_AI_API_KEY env var.\n" + + "Get your API key from https://crof.ai/usage_api/" + ); + } + + function readSessionKey(ctx) { + var raw = ctx.host.env.get("CROF_AI_SESSION_KEY"); + if (typeof raw === "string" && raw.trim()) { + return raw.trim(); + } + + var keyPath = ctx.app.pluginDataDir + "/session-key"; + if (!ctx.host.fs.exists(keyPath)) { + return null; + } + try { + var key = ctx.host.fs.readText(keyPath).trim(); + return key || null; + } catch (e) { + return null; + } + } + + function requestJson(ctx, opts) { + var resp; + try { + resp = ctx.host.http.request({ + method: "GET", + url: opts.url, + headers: opts.headers || {}, + Accept: "application/json", + timeoutMs: TIMEOUT_MS, + }); + } catch (e) { + throw "Crof.AI network error. Check your connection."; + } + + if (resp.status === 401 || resp.status === 403) { + throw "Crof.AI auth expired. Check your API key or session key."; + } + + if (resp.status !== 200) { + throw "Crof.AI API error (HTTP " + resp.status + "). Try again later."; + } + + var parsed = ctx.util.tryParseJson(resp.bodyText); + if (parsed === null) { + throw "Invalid response from Crof.AI. Try again later."; + } + return parsed; + } + + function planFullName(raw) { + if (!raw) return null; + var lower = String(raw).toLowerCase().trim(); + return PLAN_NAMES[lower] || null; + } + + function formatTokens(count) { + if (count >= 1e9) { + var b = count / 1e9; + return (Math.round(b * 10) / 10).toFixed(1) + "B"; + } + if (count >= 1e6) { + var m = count / 1e6; + return (Math.round(m * 10) / 10).toFixed(1) + "M"; + } + if (count >= 1e3) { + var k = count / 1e3; + return (Math.round(k * 10) / 10).toFixed(1) + "K"; + } + return String(count); + } + + function formatCredits(raw) { + var num = Number(raw); + if (!Number.isFinite(num)) return "$0.00"; + var abs = Math.abs(num); + if (abs < 0.01) return "$0.00"; + var sign = num < 0 ? "-" : ""; + return sign + "$" + abs.toFixed(2); + } + + function buildLines(ctx, usageData, requestsCount, creditsValue, planInfo) { + var lines = []; + + var maxRequests = FALLBACK_MAX_REQUESTS; + if ( + planInfo && + typeof planInfo.requests === "number" && + planInfo.requests > 0 + ) { + maxRequests = planInfo.requests; + } + + var used = 0; + if (requestsCount !== null) { + used = maxRequests - requestsCount; + if (used < 0) used = 0; + } + + lines.push( + ctx.line.progress({ + label: "Requests", + used: used, + limit: maxRequests, + format: { kind: "count", suffix: "requests" }, + }) + ); + + lines.push( + ctx.line.text({ + label: "Credits", + value: formatCredits(creditsValue), + }) + ); + + var models = []; + var totalTokens = 0; + + if (usageData && typeof usageData === "object") { + for (var key in usageData) { + if (Object.prototype.hasOwnProperty.call(usageData, key)) { + var model = usageData[key]; + var tt = model.total_tokens || model.totalTokens || 0; + if (tt > 0) { + totalTokens += tt; + models.push({ name: key, tokens: tt }); + } + } + } + } + + lines.push( + ctx.line.text({ + label: "Total tokens", + value: formatTokens(totalTokens), + subtitle: models.length > 0 ? models.length + " models" : undefined, + }) + ); + + models.sort(function (a, b) { + return b.tokens - a.tokens; + }); + + var topModels = models.slice(0, MAX_DISPLAY_MODELS); + for (var i = 0; i < topModels.length; i++) { + var m = topModels[i]; + lines.push( + ctx.line.text({ + label: m.name, + value: formatTokens(m.tokens) + " tokens", + }) + ); + } + + return lines; + } + + function probe(ctx) { + var apiKey = readApiKey(ctx); + + var usageResp = requestJson(ctx, { + url: USAGE_API_URL, + headers: { + Authorization: "Bearer " + apiKey, + }, + }); + + if (!usageResp || typeof usageResp !== "object" || Array.isArray(usageResp)) { + throw "Invalid response from Crof.AI. Try again later."; + } + + var hasCredits = "credits" in usageResp; + var creditsValue = 0; + if (hasCredits) { + if (typeof usageResp.credits !== "number" || !Number.isFinite(usageResp.credits)) { + throw "Invalid response from Crof.AI. Try again later."; + } + creditsValue = usageResp.credits; + } + + var requestsCount = null; + var hasRequests = "usable_requests" in usageResp; + if (hasRequests) { + if (usageResp.usable_requests !== null) { + if (typeof usageResp.usable_requests !== "number" || !Number.isFinite(usageResp.usable_requests)) { + throw "Invalid response from Crof.AI. Try again later."; + } + requestsCount = usageResp.usable_requests; + } + } + + var sessionKey = readSessionKey(ctx); + + var userUsageData = null; + var planInfo = null; + var planName = null; + + if (sessionKey) { + try { + userUsageData = requestJson(ctx, { + url: USER_USAGE_URL, + headers: { + Cookie: "session=" + sessionKey, + }, + }); + } catch (e) { + ctx.host.log.warn("Crof.AI user usage fetch failed: " + String(e)); + } + + try { + planInfo = requestJson(ctx, { + url: PRICING_URL, + headers: { + Cookie: "session=" + sessionKey, + }, + }); + if (planInfo && planInfo.name) { + planName = planFullName(planInfo.name) || ctx.fmt.planLabel(planInfo.name); + } + } catch (e) { + ctx.host.log.warn("Crof.AI pricing fetch failed: " + String(e)); + } + } + + return { + plan: planName, + lines: buildLines(ctx, userUsageData, requestsCount, creditsValue, planInfo), + }; + } + + globalThis.__openusage_plugin = { id: PROVIDER_ID, probe }; +})(); diff --git a/plugins/crofai/plugin.json b/plugins/crofai/plugin.json new file mode 100644 index 00000000..ace08af2 --- /dev/null +++ b/plugins/crofai/plugin.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "crofai", + "name": "Crof.AI", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#6B52F2", + "links": [ + { + "label": "Dashboard", + "url": "https://crof.ai" + } + ], + "lines": [ + { "type": "badge", "label": "Status", "scope": "overview" }, + { "type": "progress", "label": "Requests", "scope": "overview", "primaryOrder": 1 }, + { "type": "text", "label": "Credits", "scope": "overview" }, + { "type": "text", "label": "Total tokens", "scope": "overview" }, + { "type": "text", "label": "Models", "scope": "detail" } + ] +} diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js new file mode 100644 index 00000000..5a50f81c --- /dev/null +++ b/plugins/crofai/plugin.test.js @@ -0,0 +1,452 @@ +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeCtx } from "../test-helpers.js"; + +const PLUGIN_DATA_DIR = "/tmp/openusage-test/plugin"; +const SESSION_KEY_PATH = PLUGIN_DATA_DIR + "/session-key"; +const API_KEY_ENV = "CROF_AI_API_KEY"; +const SESSION_KEY_ENV = "CROF_AI_SESSION_KEY"; + +const loadPlugin = async () => { + await import("./plugin.js"); + return globalThis.__openusage_plugin; +}; + +function setEnv(ctx, name, value) { + if (!ctx._env) ctx._env = {}; + ctx._env[name] = value; + ctx.host.env.get.mockImplementation(function (n) { + return ctx._env[n] !== undefined ? ctx._env[n] : null; + }); +} + +function setSessionKeyFile(ctx, value) { + ctx.host.fs.writeText(SESSION_KEY_PATH, value); +} + +function mockHttp(ctx, options) { + var usageApiBody = + (options && options.usageApiBody !== undefined) + ? options.usageApiBody + : JSON.stringify({ usable_requests: 1938, credits: 42.5 }); + + var pricingBody = + (options && options.pricingBody !== undefined) + ? options.pricingBody + : JSON.stringify({ cost: 20, name: "int", requests: 2500, type: "normal" }); + + var userUsageBody = + (options && options.userUsageBody !== undefined) + ? options.userUsageBody + : JSON.stringify({ + "deepseek-v4-pro": { + input_tokens: 1083491205, + output_tokens: 6249083, + total_tokens: 1089740288, + }, + "deepseek-v4-pro-precision": { + input_tokens: 119901643, + output_tokens: 517164, + total_tokens: 120418807, + }, + "glm-4.7-flash": { + input_tokens: 1527962, + output_tokens: 26246, + total_tokens: 1554208, + }, + "glm-5.1": { + input_tokens: 7320364, + output_tokens: 31617, + total_tokens: 7351981, + }, + "kimi-k2.5": { + input_tokens: 6896, + output_tokens: 4808, + total_tokens: 11704, + }, + "kimi-k2.5-lightning": { + input_tokens: 6980, + output_tokens: 840, + total_tokens: 7820, + }, + "kimi-k2.6": { + input_tokens: 36754651, + output_tokens: 232694, + total_tokens: 36987345, + }, + }); + + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url === "https://crof.ai/usage_api/") { + return { status: 200, headers: {}, bodyText: String(usageApiBody) }; + } + if (opts.url === "https://crof.ai/user-api/usage") { + return { status: 200, headers: {}, bodyText: String(userUsageBody) }; + } + if (opts.url === "https://crof.ai/pricing_api") { + return { status: 200, headers: {}, bodyText: String(pricingBody) }; + } + return { status: 404, headers: {}, bodyText: "Not found" }; + }); +} + +describe("crofai plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin; + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + // ---- Manifest ---- + + it("ships plugin metadata with links and expected line layout", () => { + var manifest = JSON.parse( + readFileSync("plugins/crofai/plugin.json", "utf8"), + ); + + expect(manifest.id).toBe("crofai"); + expect(manifest.name).toBe("Crof.AI"); + expect(manifest.brandColor).toBe("#6B52F2"); + expect(manifest.links).toEqual([ + { label: "Dashboard", url: "https://crof.ai" }, + ]); + expect(manifest.lines).toEqual([ + { type: "badge", label: "Status", scope: "overview" }, + { type: "progress", label: "Requests", scope: "overview", primaryOrder: 1 }, + { type: "text", label: "Credits", scope: "overview" }, + { type: "text", label: "Total tokens", scope: "overview" }, + { type: "text", label: "Models", scope: "detail" }, + ]); + }); + + // ---- Auth errors ---- + + it("throws when API key env var is missing", async () => { + var ctx = makeCtx(); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow( + "Crof.AI not configured. Set CROF_AI_API_KEY", + ); + }); + + it("throws on network error", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockImplementation(() => { throw new Error("refused"); }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Crof.AI network error"); + }); + + it("throws on 401", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Crof.AI auth expired"); + }); + + // ---- Response validation ---- + + it("throws on array response body", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, bodyText: JSON.stringify([1, 2]), + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when credits is Infinity", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: '{"credits":1e999,"usable_requests":10}', + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when usable_requests is a string", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ credits: 10, usable_requests: "50" }), + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when usable_requests is a boolean", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ credits: 10, usable_requests: true }), + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when credits is a string", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ credits: "abc", usable_requests: 10 }), + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when usable_requests is NaN via large exponent", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: '{"credits":10,"usable_requests":NaN}', + }); + var plugin = await loadPlugin(); + expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); + }); + + it("works with null usable_requests (no subscription)", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ credits: 5, usable_requests: null }), + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); + expect(result.lines[1].value).toBe("$5.00"); + }); + + it("works when usable_requests field is missing entirely", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ credits: 5 }), + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); + expect(result.lines[1].value).toBe("$5.00"); + }); + + it("works when credits field is missing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, + bodyText: JSON.stringify({ usable_requests: 100 }), + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[1].value).toBe("$0.00"); + expect(result.lines[0].used).toBe(14900); + }); + + // ---- Plan field ---- + + it("returns no plan when no session key", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockHttp(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBeNull(); + }); + + it("returns plan name when session key has pricing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Intermediate"); + }); + + it("returns plan for hobby pricing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { pricingBody: JSON.stringify({ name: "hobby", requests: 500 }) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Hobby"); + }); + + it("returns plan for pro pricing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { pricingBody: JSON.stringify({ name: "pro", requests: 1000 }) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Pro"); + }); + + it("returns plan for scale pricing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { pricingBody: JSON.stringify({ name: "scale", requests: 7500 }) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Scale"); + }); + + it("returns plan for max pricing", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { pricingBody: JSON.stringify({ name: "max", requests: 15000 }) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Max"); + }); + + it("falls through to fmt.planLabel for unknown plan names", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { pricingBody: JSON.stringify({ name: "enterprise_plus", requests: 9999 }) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Enterprise_plus"); + }); + + // ---- Progress bar ---- + + it("shows progress bar with 15000 fallback", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockHttp(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ + type: "progress", + label: "Requests", + used: 13062, + limit: 15000, + }); + }); + + it("shows progress bar with pricing data", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ + type: "progress", + label: "Requests", + used: 562, + limit: 2500, + }); + }); + + it("gracefully falls back on pricing failure", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url === "https://crof.ai/usage_api/") { + return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 500, credits: 10 }) }; + } + return { status: 403, headers: {}, bodyText: "" }; + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ label: "Requests", limit: 15000 }); + expect(result.plan).toBeNull(); + }); + + // ---- Content lines ---- + + it("shows credits and tokens", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$42.50" }); + expect(result.lines[2]).toMatchObject({ label: "Total tokens", value: "1.3B" }); + }); + + it("shows model details", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + var models = result.lines.slice(3); + expect(models.length).toBe(5); + expect(models[0].label).toBe("deepseek-v4-pro"); + }); + + it("limits to 5 models", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + var many = {}; + for (var i = 0; i < 10; i++) many["m-" + i] = { total_tokens: (10 - i) * 1000 }; + mockHttp(ctx, { userUsageBody: JSON.stringify(many) }); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).lines.slice(3).length).toBe(5); + }); + + it("sorts models descending", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockHttp(ctx, { userUsageBody: JSON.stringify({ a: { total_tokens: 100 }, b: { total_tokens: 500 } }) }); + var plugin = await loadPlugin(); + var models = plugin.probe(ctx).lines.slice(3); + expect(models[0].label).toBe("b"); + expect(models[1].label).toBe("a"); + }); + + // ---- Session key sources ---- + + it("reads session key from file", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setSessionKeyFile(ctx, "file-session"); + mockHttp(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.plan).toBe("Intermediate"); + }); + + it("env var takes priority over file", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "env-session"); + setSessionKeyFile(ctx, "file-session"); + mockHttp(ctx); + var plugin = await loadPlugin(); + + var calls = ctx.host.http.request.mock.calls; + for (var i = 0; i < calls.length; i++) { + var opts = calls[i][0]; + if (opts.url === "https://crof.ai/pricing_api") { + expect(opts.headers.Cookie).toBe("session=env-session"); + } + } + }); +}); diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index a39ac09d..809d0c04 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -const WHITELISTED_ENV_VARS: [&str; 16] = [ +const WHITELISTED_ENV_VARS: &[&str] = &[ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", @@ -29,6 +29,8 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [ "MINIMAX_CN_API_KEY", "SYNTHETIC_API_KEY", "PI_CODING_AGENT_DIR", + "CROF_AI_SESSION_KEY", + "CROF_AI_API_KEY", ]; fn last_non_empty_trimmed_line(text: &str) -> Option { From 80fa650a02e09d21ba34aea08ef4cbb4c37d7f5d Mon Sep 17 00:00:00 2001 From: "gabriele.palaj" Date: Sun, 10 May 2026 12:23:33 +0200 Subject: [PATCH 2/5] fix(crofai): use requests_plan from usage API as progress bar fallback Replaces hardcoded 15000 fallback with requests_plan from /usage_api/ response when no session key is available. Falls through to 15000 only when both pricing API and usage API lack plan info. Also updates icon.svg to use the CrofAI letterform logo from /tmp/crofai.svg. --- plugins/crofai/icon.svg | 50 ++++++++++++++++++++++++++++++++--- plugins/crofai/plugin.js | 21 +++++++++++++-- plugins/crofai/plugin.test.js | 17 ++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/plugins/crofai/icon.svg b/plugins/crofai/icon.svg index e8fe63f9..7b949649 100644 --- a/plugins/crofai/icon.svg +++ b/plugins/crofai/icon.svg @@ -1,5 +1,47 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js index 2b91dd3d..87c39b6b 100644 --- a/plugins/crofai/plugin.js +++ b/plugins/crofai/plugin.js @@ -104,7 +104,7 @@ return sign + "$" + abs.toFixed(2); } - function buildLines(ctx, usageData, requestsCount, creditsValue, planInfo) { + function buildLines(ctx, usageData, requestsCount, creditsValue, planInfo, usagePlanRequests) { var lines = []; var maxRequests = FALLBACK_MAX_REQUESTS; @@ -114,6 +114,12 @@ planInfo.requests > 0 ) { maxRequests = planInfo.requests; + } else if ( + typeof usagePlanRequests === "number" && + Number.isFinite(usagePlanRequests) && + usagePlanRequests > 0 + ) { + maxRequests = usagePlanRequests; } var used = 0; @@ -214,6 +220,17 @@ } } + var usagePlanRequests = null; + if ("requests_plan" in usageResp) { + if ( + typeof usageResp.requests_plan === "number" && + Number.isFinite(usageResp.requests_plan) && + usageResp.requests_plan > 0 + ) { + usagePlanRequests = usageResp.requests_plan; + } + } + var sessionKey = readSessionKey(ctx); var userUsageData = null; @@ -249,7 +266,7 @@ return { plan: planName, - lines: buildLines(ctx, userUsageData, requestsCount, creditsValue, planInfo), + lines: buildLines(ctx, userUsageData, requestsCount, creditsValue, planInfo, usagePlanRequests), }; } diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js index 5a50f81c..c8c1816b 100644 --- a/plugins/crofai/plugin.test.js +++ b/plugins/crofai/plugin.test.js @@ -355,6 +355,23 @@ describe("crofai plugin", () => { }); }); + it("uses requests_plan from usage API when no session key", async () => { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ + status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 300, credits: 10, requests_plan: 1000 }), + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines[0]).toMatchObject({ + type: "progress", + label: "Requests", + used: 700, + limit: 1000, + }); + }); + it("gracefully falls back on pricing failure", async () => { var ctx = makeCtx(); setEnv(ctx, API_KEY_ENV, "k"); From 8d18e1477246bc9c379e266c10c34dbd8bed72a3 Mon Sep 17 00:00:00 2001 From: "gabriele.palaj" Date: Sun, 10 May 2026 12:39:08 +0200 Subject: [PATCH 3/5] fix(crofai): omit negative credits, add extensive test suite - Credits line omitted when negative (matching PR #412 pattern) - Added 82 tests covering all edge cases from PR #412 review - Covers: auth, HTTP errors, response validation, progress bar sources, fallback chains, credits display, plan names, session key sources, token formatting, model sorting, error messages - Previous 30 tests expanded to 82 with 52 new test cases --- plugins/crofai/icon.svg | 49 +- plugins/crofai/plugin.js | 14 +- plugins/crofai/plugin.test.js | 1169 +++++++++++++++++++++++---------- 3 files changed, 827 insertions(+), 405 deletions(-) diff --git a/plugins/crofai/icon.svg b/plugins/crofai/icon.svg index 7b949649..f2eb1490 100644 --- a/plugins/crofai/icon.svg +++ b/plugins/crofai/icon.svg @@ -1,47 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js index 87c39b6b..cf96f029 100644 --- a/plugins/crofai/plugin.js +++ b/plugins/crofai/plugin.js @@ -137,12 +137,14 @@ }) ); - lines.push( - ctx.line.text({ - label: "Credits", - value: formatCredits(creditsValue), - }) - ); + if (creditsValue >= 0) { + lines.push( + ctx.line.text({ + label: "Credits", + value: formatCredits(creditsValue), + }) + ); + } var models = []; var totalTokens = 0; diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js index c8c1816b..c4ade01c 100644 --- a/plugins/crofai/plugin.test.js +++ b/plugins/crofai/plugin.test.js @@ -2,12 +2,15 @@ import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeCtx } from "../test-helpers.js"; -const PLUGIN_DATA_DIR = "/tmp/openusage-test/plugin"; -const SESSION_KEY_PATH = PLUGIN_DATA_DIR + "/session-key"; -const API_KEY_ENV = "CROF_AI_API_KEY"; -const SESSION_KEY_ENV = "CROF_AI_SESSION_KEY"; - -const loadPlugin = async () => { +var PLUGIN_DATA_DIR = "/tmp/openusage-test/plugin"; +var SESSION_KEY_PATH = PLUGIN_DATA_DIR + "/session-key"; +var API_KEY_ENV = "CROF_AI_API_KEY"; +var SESSION_KEY_ENV = "CROF_AI_SESSION_KEY"; +var USAGE_API_URL = "https://crof.ai/usage_api/"; +var USER_USAGE_URL = "https://crof.ai/user-api/usage"; +var PRICING_URL = "https://crof.ai/pricing_api"; + +var loadPlugin = async function () { await import("./plugin.js"); return globalThis.__openusage_plugin; }; @@ -24,96 +27,55 @@ function setSessionKeyFile(ctx, value) { ctx.host.fs.writeText(SESSION_KEY_PATH, value); } -function mockHttp(ctx, options) { - var usageApiBody = - (options && options.usageApiBody !== undefined) - ? options.usageApiBody - : JSON.stringify({ usable_requests: 1938, credits: 42.5 }); - - var pricingBody = - (options && options.pricingBody !== undefined) - ? options.pricingBody - : JSON.stringify({ cost: 20, name: "int", requests: 2500, type: "normal" }); - - var userUsageBody = - (options && options.userUsageBody !== undefined) - ? options.userUsageBody - : JSON.stringify({ - "deepseek-v4-pro": { - input_tokens: 1083491205, - output_tokens: 6249083, - total_tokens: 1089740288, - }, - "deepseek-v4-pro-precision": { - input_tokens: 119901643, - output_tokens: 517164, - total_tokens: 120418807, - }, - "glm-4.7-flash": { - input_tokens: 1527962, - output_tokens: 26246, - total_tokens: 1554208, - }, - "glm-5.1": { - input_tokens: 7320364, - output_tokens: 31617, - total_tokens: 7351981, - }, - "kimi-k2.5": { - input_tokens: 6896, - output_tokens: 4808, - total_tokens: 11704, - }, - "kimi-k2.5-lightning": { - input_tokens: 6980, - output_tokens: 840, - total_tokens: 7820, - }, - "kimi-k2.6": { - input_tokens: 36754651, - output_tokens: 232694, - total_tokens: 36987345, - }, - }); - - ctx.host.http.request.mockImplementation((opts) => { - if (opts.url === "https://crof.ai/usage_api/") { - return { status: 200, headers: {}, bodyText: String(usageApiBody) }; - } - if (opts.url === "https://crof.ai/user-api/usage") { - return { status: 200, headers: {}, bodyText: String(userUsageBody) }; - } - if (opts.url === "https://crof.ai/pricing_api") { - return { status: 200, headers: {}, bodyText: String(pricingBody) }; - } +function mockUsageApi(ctx, body) { + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: String(body) }; + return { status: 404, headers: {}, bodyText: "Not found" }; + }); +} + +function mockAllApis(ctx, options) { + var usageBody = (options && options.usageBody !== undefined) + ? options.usageBody + : JSON.stringify({ usable_requests: 1938, credits: 42.5 }); + + var pricingBody = (options && options.pricingBody !== undefined) + ? options.pricingBody + : JSON.stringify({ cost: 20, name: "int", requests: 2500, type: "normal" }); + + var usageDetailBody = (options && options.usageDetailBody !== undefined) + ? options.usageDetailBody + : JSON.stringify({ + "deepseek-v4-pro": { input_tokens: 1083491205, output_tokens: 6249083, total_tokens: 1089740288 }, + "kimi-k2.6": { input_tokens: 36754651, output_tokens: 232694, total_tokens: 36987345 }, + }); + + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: String(usageBody) }; + if (opts.url === USER_USAGE_URL) return { status: 200, headers: {}, bodyText: String(usageDetailBody) }; + if (opts.url === PRICING_URL) return { status: 200, headers: {}, bodyText: String(pricingBody) }; return { status: 404, headers: {}, bodyText: "Not found" }; }); } -describe("crofai plugin", () => { - beforeEach(() => { +describe("crofai plugin", function () { + beforeEach(function () { delete globalThis.__openusage_plugin; vi.resetModules(); }); - afterEach(() => { + afterEach(function () { vi.restoreAllMocks(); - vi.useRealTimers(); }); - // ---- Manifest ---- - - it("ships plugin metadata with links and expected line layout", () => { - var manifest = JSON.parse( - readFileSync("plugins/crofai/plugin.json", "utf8"), - ); + // ── Manifest ── + it("ships plugin metadata with links and expected line layout", function () { + var manifest = JSON.parse(readFileSync("plugins/crofai/plugin.json", "utf8")); expect(manifest.id).toBe("crofai"); expect(manifest.name).toBe("Crof.AI"); expect(manifest.brandColor).toBe("#6B52F2"); - expect(manifest.links).toEqual([ - { label: "Dashboard", url: "https://crof.ai" }, - ]); + expect(manifest.links).toEqual([{ label: "Dashboard", url: "https://crof.ai" }]); expect(manifest.lines).toEqual([ { type: "badge", label: "Status", scope: "overview" }, { type: "progress", label: "Requests", scope: "overview", primaryOrder: 1 }, @@ -123,347 +85,848 @@ describe("crofai plugin", () => { ]); }); - // ---- Auth errors ---- + // ── Auth: API key validation ── - it("throws when API key env var is missing", async () => { - var ctx = makeCtx(); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow( - "Crof.AI not configured. Set CROF_AI_API_KEY", - ); - }); + describe("auth - API key", function () { + it("throws when CROF_AI_API_KEY is missing", async function () { + var ctx = makeCtx(); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); + }); - it("throws on network error", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockImplementation(() => { throw new Error("refused"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Crof.AI network error"); - }); + it("throws when CROF_AI_API_KEY is empty string", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, ""); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); + }); - it("throws on 401", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Crof.AI auth expired"); - }); + it("throws when CROF_AI_API_KEY is only whitespace", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, " \t "); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); + }); - // ---- Response validation ---- + it("throws when CROF_AI_API_KEY is null", async function () { + var ctx = makeCtx(); + ctx.host.env.get.mockReturnValue(null); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); + }); - it("throws on array response body", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, bodyText: JSON.stringify([1, 2]), + it("throws when CROF_AI_API_KEY is a non-string (number)", async function () { + var ctx = makeCtx(); + ctx.host.env.get.mockReturnValue(123); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); - }); - it("throws when credits is Infinity", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: '{"credits":1e999,"usable_requests":10}', + it("sends GET with Bearer auth to correct URL", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "test-api-key"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 10 })); + var plugin = await loadPlugin(); + plugin.probe(ctx); + + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.method).toBe("GET"); + expect(call.url).toBe(USAGE_API_URL); + expect(call.headers.Authorization).toBe("Bearer test-api-key"); + expect(call.Accept).toBe("application/json"); + expect(call.timeoutMs).toBe(10000); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); }); - it("throws when usable_requests is a string", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ credits: 10, usable_requests: "50" }), + // ── HTTP error handling ── + + describe("HTTP error handling", function () { + it("throws on network error", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockImplementation(function () { throw new Error("ECONNREFUSED"); }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI network error"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); - }); - it("throws when usable_requests is a boolean", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ credits: 10, usable_requests: true }), + it("throws on HTTP 401", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI auth expired"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); - }); - it("throws when credits is a string", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ credits: "abc", usable_requests: 10 }), + it("throws on HTTP 403", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 403, headers: {}, bodyText: "" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI auth expired"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); - }); - it("throws when usable_requests is NaN via large exponent", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: '{"credits":10,"usable_requests":NaN}', + it("throws on HTTP 500", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 500, headers: {}, bodyText: "Server error" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI API error (HTTP 500)"); }); - var plugin = await loadPlugin(); - expect(() => plugin.probe(ctx)).toThrow("Invalid response from Crof.AI"); - }); - it("works with null usable_requests (no subscription)", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ credits: 5, usable_requests: null }), + it("throws on HTTP 300 (non-2xx boundary)", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 300, headers: {}, bodyText: "Redirect" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI API error (HTTP 300)"); }); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); - expect(result.lines[1].value).toBe("$5.00"); + it("throws on HTTP 404", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 404, headers: {}, bodyText: "Not found" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI API error (HTTP 404)"); + }); }); - it("works when usable_requests field is missing entirely", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ credits: 5 }), + // ── Response validation: body shape ── + + describe("response validation - body shape", function () { + it("throws on unparseable JSON", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, "not-json"); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); }); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); - expect(result.lines[1].value).toBe("$5.00"); - }); + it("throws on null bodyText", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: null }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); - it("works when credits field is missing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, - bodyText: JSON.stringify({ usable_requests: 100 }), + it("throws on empty bodyText", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: "" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); }); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.lines[1].value).toBe("$0.00"); - expect(result.lines[0].used).toBe(14900); - }); + it("throws on array response body", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify([1, 2, 3])); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); - // ---- Plan field ---- + it("throws on string response body", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, '"hello"'); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); - it("returns no plan when no session key", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - mockHttp(ctx); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBeNull(); + it("throws on number response body", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, "42"); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); }); - it("returns plan name when session key has pricing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Intermediate"); - }); + // ── Response validation: credits field ── - it("returns plan for hobby pricing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { pricingBody: JSON.stringify({ name: "hobby", requests: 500 }) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Hobby"); - }); + describe("response validation - credits field", function () { + it("throws when credits is Infinity", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, '{"credits":1e999,"usable_requests":10}'); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); - it("returns plan for pro pricing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { pricingBody: JSON.stringify({ name: "pro", requests: 1000 }) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Pro"); - }); + it("throws when credits is NaN", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, '{"credits":NaN,"usable_requests":10}'); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when credits is a string", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: "abc", usable_requests: 10 })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); - it("returns plan for scale pricing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { pricingBody: JSON.stringify({ name: "scale", requests: 7500 }) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Scale"); + it("throws when credits is a boolean", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: true, usable_requests: 10 })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when credits is an object", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: {}, usable_requests: 10 })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); }); - it("returns plan for max pricing", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { pricingBody: JSON.stringify({ name: "max", requests: 15000 }) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Max"); + // ── Response validation: usable_requests field ── + + describe("response validation - usable_requests field", function () { + it("throws when usable_requests is a string", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: 10, usable_requests: "50" })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when usable_requests is a boolean", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: 10, usable_requests: false })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); + + it("throws when usable_requests is an object", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: 10, usable_requests: { x: 1 } })); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); + + it("works when usable_requests is null (no subscription)", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: 5, usable_requests: null })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); + }); + + it("works when usable_requests field is entirely absent", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: 5 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 15000 }); + }); }); - it("falls through to fmt.planLabel for unknown plan names", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { pricingBody: JSON.stringify({ name: "enterprise_plus", requests: 9999 }) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).plan).toBe("Enterprise_plus"); + // ── Progress bar ── + + describe("progress bar - limit sources", function () { + it("uses pricing API plan limit when session key available", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "int", requests: 2500 }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", limit: 2500 }); + }); + + it("uses requests_plan from usage API when no session key", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 300, credits: 10, requests_plan: 1000 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", used: 700, limit: 1000 }); + }); + + it("uses 15000 fallback when no pricing and no requests_plan", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 500, credits: 10 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", limit: 15000 }); + }); + + it("clamps used to 0 when usable_requests exceeds limit", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 200, credits: 10, requests_plan: 100 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", used: 0, limit: 100 }); + }); + + it("calculates used correctly when usable < limit", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 10, requests_plan: 500 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ type: "progress", used: 400, limit: 500 }); + }); + + it("pricing API takes priority over usage requests_plan", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + // usage API has requests_plan = 500, but pricing has requests = 2500 + mockAllApis(ctx, { + usageBody: JSON.stringify({ usable_requests: 100, credits: 10, requests_plan: 500 }), + pricingBody: JSON.stringify({ name: "int", requests: 2500 }), + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ limit: 2500 }); + }); }); - // ---- Progress bar ---- + // ── Progress bar: fallback on session-dependent API failures ── + + describe("progress bar - fallback on failures", function () { + it("falls back from pricing failure to usage requests_plan", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 300, credits: 10, requests_plan: 1000 }) }; + if (opts.url === PRICING_URL) return { status: 500, headers: {}, bodyText: "" }; + return { status: 404, headers: {}, bodyText: "" }; + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ limit: 1000 }); + }); - it("shows progress bar with 15000 fallback", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - mockHttp(ctx); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); + it("falls back to 15000 when both pricing and requests_plan fail", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 300, credits: 10 }) }; + if (opts.url === PRICING_URL) return { status: 500, headers: {}, bodyText: "" }; + return { status: 404, headers: {}, bodyText: "" }; + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0]).toMatchObject({ limit: 15000 }); + expect(result.plan).toBeNull(); + }); - expect(result.lines[0]).toMatchObject({ - type: "progress", - label: "Requests", - used: 13062, - limit: 15000, + it("pricing failure logs warning and returns no plan", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 500, credits: 10 }) }; + return { status: 403, headers: {}, bodyText: "" }; + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(ctx.host.log.warn).toHaveBeenCalled(); + expect(result.plan).toBeNull(); + }); + + it("user usage failure logs warning but does not affect main lines", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + ctx.host.http.request.mockImplementation(function (opts) { + if (opts.url === USAGE_API_URL) return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 500, credits: 10 }) }; + if (opts.url === USER_USAGE_URL) return { status: 500, headers: {}, bodyText: "" }; + if (opts.url === PRICING_URL) return { status: 200, headers: {}, bodyText: JSON.stringify({ name: "int", requests: 2500 }) }; + return { status: 404, headers: {}, bodyText: "" }; + }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(ctx.host.log.warn).toHaveBeenCalled(); + expect(result.lines[0]).toMatchObject({ type: "progress", limit: 2500 }); + expect(result.plan).toBe("Intermediate"); }); }); - it("shows progress bar with pricing data", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); + // ── Credits display ── + + describe("credits display", function () { + it("shows positive credits formatted as dollars", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 42.5 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$42.50" }); + }); + + it("shows zero credits as $0.00", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 0 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$0.00" }); + }); + + it("omits credits line when credits is negative", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: -5 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Credits"; })).toBeUndefined(); + }); - expect(result.lines[0]).toMatchObject({ - type: "progress", - label: "Requests", - used: 562, - limit: 2500, + it("shows very small positive credits (< $0.01) as $0.00", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 0.005 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$0.00" }); + }); + + it("shows $0.00 when credits field is missing from response", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$0.00" }); + }); + + it("formats credits with two decimal places", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 12.3456 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[1].value).toBe("$12.35"); }); }); - it("uses requests_plan from usage API when no session key", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - ctx.host.http.request.mockReturnValue({ - status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 300, credits: 10, requests_plan: 1000 }), + // ── Plan name ── + + describe("plan name", function () { + it("returns null plan when no session key", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockAllApis(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBeNull(); + }); + + it("returns mapped plan name for int", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "int", requests: 2500 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Intermediate"); + }); + + it("returns mapped plan name for hobby", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "hobby", requests: 500 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Hobby"); }); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.lines[0]).toMatchObject({ - type: "progress", - label: "Requests", - used: 700, - limit: 1000, + it("returns mapped plan name for pro", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "pro", requests: 1000 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Pro"); + }); + + it("returns mapped plan name for scale", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "scale", requests: 7500 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Scale"); + }); + + it("returns mapped plan name for max", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "max", requests: 15000 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Max"); + }); + + it("falls through to fmt.planLabel for unknown plan names", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "enterprise_plus", requests: 9999 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Enterprise_plus"); + }); + + it("handles case-insensitive plan names", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ name: "INT", requests: 2500 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Intermediate"); + }); + + it("returns null plan when pricing has no name field", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { pricingBody: JSON.stringify({ requests: 2500 }) }); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBeNull(); }); }); - it("gracefully falls back on pricing failure", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - ctx.host.http.request.mockImplementation((opts) => { - if (opts.url === "https://crof.ai/usage_api/") { - return { status: 200, headers: {}, bodyText: JSON.stringify({ usable_requests: 500, credits: 10 }) }; + // ── Session key sources ── + + describe("session key sources", function () { + it("reads session key from env var", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "env-session"); + mockAllApis(ctx); + var p = await loadPlugin(); + expect(p.probe(ctx).plan).toBe("Intermediate"); + }); + + it("reads session key from file", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setSessionKeyFile(ctx, "file-session"); + mockAllApis(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBe("Intermediate"); + }); + + it("env var takes priority over file", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "env-session"); + setSessionKeyFile(ctx, "file-session"); + mockAllApis(ctx); + var plugin = await loadPlugin(); + var calls = ctx.host.http.request.mock.calls; + for (var i = 0; i < calls.length; i++) { + var opts = calls[i][0]; + if (opts.url === PRICING_URL) { + expect(opts.headers.Cookie).toBe("session=env-session"); + } } - return { status: 403, headers: {}, bodyText: "" }; }); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.lines[0]).toMatchObject({ label: "Requests", limit: 15000 }); - expect(result.plan).toBeNull(); - }); + it("returns null session key when env var is empty and file missing", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, ""); + mockAllApis(ctx); + var plugin = await loadPlugin(); + // Should still work with just API key data + var result = plugin.probe(ctx); + expect(result.plan).toBeNull(); + expect(result.lines[0]).toBeDefined(); + }); - // ---- Content lines ---- + it("returns null session key when file is empty", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setSessionKeyFile(ctx, ""); + mockAllApis(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBeNull(); + }); - it("shows credits and tokens", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); + it("returns null session key when file only has whitespace", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setSessionKeyFile(ctx, " \n "); + mockAllApis(ctx); + var plugin = await loadPlugin(); + expect(plugin.probe(ctx).plan).toBeNull(); + }); - expect(result.lines[1]).toMatchObject({ label: "Credits", value: "$42.50" }); - expect(result.lines[2]).toMatchObject({ label: "Total tokens", value: "1.3B" }); + it("uses session key via API calls to user-usage and pricing", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "my-session-key"); + mockAllApis(ctx); + var plugin = await loadPlugin(); + plugin.probe(ctx); + + var usageCall = ctx.host.http.request.mock.calls[1]; + var pricingCall = ctx.host.http.request.mock.calls[2]; + expect(usageCall[0].url).toBe(USER_USAGE_URL); + expect(usageCall[0].headers.Cookie).toBe("session=my-session-key"); + expect(pricingCall[0].url).toBe(PRICING_URL); + expect(pricingCall[0].headers.Cookie).toBe("session=my-session-key"); + }); }); - it("shows model details", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - - var models = result.lines.slice(3); - expect(models.length).toBe(5); - expect(models[0].label).toBe("deepseek-v4-pro"); - }); + // ── Tokens and models ── + + describe("tokens and models", function () { + it("shows total tokens line", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ "model-big": { total_tokens: 5000000 }, "model-small": { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var totalLine = result.lines.find(function (l) { return l.label === "Total tokens"; }); + expect(totalLine).toBeDefined(); + }); + + it("shows model count in subtitle", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ "model-big": { total_tokens: 5000000 }, "model-small": { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var totalLine = result.lines.find(function (l) { return l.label === "Total tokens"; }); + expect(totalLine.subtitle).toBe("2 models"); + }); + + it("sorts models descending by token count", async function () { + var ctx2 = makeCtx(); + setEnv(ctx2, API_KEY_ENV, "k"); + setEnv(ctx2, SESSION_KEY_ENV, "s"); + mockAllApis(ctx2, { + usageDetailBody: JSON.stringify({ a: { total_tokens: 100 }, b: { total_tokens: 500 } }), + }); + var p2 = await loadPlugin(); + var models = p2.probe(ctx2).lines.filter(function (l) { return l.label !== "Requests" && l.label !== "Credits" && l.label !== "Total tokens"; }); + expect(models[0].label).toBe("b"); + expect(models[1].label).toBe("a"); + }); - it("limits to 5 models", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - var many = {}; - for (var i = 0; i < 10; i++) many["m-" + i] = { total_tokens: (10 - i) * 1000 }; - mockHttp(ctx, { userUsageBody: JSON.stringify(many) }); - var plugin = await loadPlugin(); - expect(plugin.probe(ctx).lines.slice(3).length).toBe(5); + it("limits to 5 models maximum", async function () { + var many = {}; + for (var i = 1; i <= 10; i++) many["m-" + i] = { total_tokens: (10 - i + 1) * 1000 }; + var ctx3 = makeCtx(); + setEnv(ctx3, API_KEY_ENV, "k"); + setEnv(ctx3, SESSION_KEY_ENV, "s"); + mockAllApis(ctx3, { usageDetailBody: JSON.stringify(many) }); + var p3 = await loadPlugin(); + var models = p3.probe(ctx3).lines.filter(function (l) { return /tokens/.test(l.value || ""); }); + expect(models.length).toBe(5); + }); + + it("skips models with zero tokens", async function () { + var ctx4 = makeCtx(); + setEnv(ctx4, API_KEY_ENV, "k"); + setEnv(ctx4, SESSION_KEY_ENV, "s"); + mockAllApis(ctx4, { + usageDetailBody: JSON.stringify({ a: { total_tokens: 100 }, b: { total_tokens: 0 }, c: { total_tokens: 200 } }), + }); + var p4 = await loadPlugin(); + var result = p4.probe(ctx4); + var models = result.lines.filter(function (l) { return /tokens/.test(l.value || ""); }); + expect(models.length).toBe(2); + }); + + it("handles camelCase totalTokens field", async function () { + var ctx5 = makeCtx(); + setEnv(ctx5, API_KEY_ENV, "k"); + setEnv(ctx5, SESSION_KEY_ENV, "s"); + mockAllApis(ctx5, { + usageDetailBody: JSON.stringify({ a: { totalTokens: 3000 } }), + }); + var p5 = await loadPlugin(); + var result = p5.probe(ctx5); + var modelLine = result.lines.find(function (l) { return l.label === "a"; }); + expect(modelLine).toBeDefined(); + expect(modelLine.value).toBe("3.0K tokens"); + }); + + it("shows no model lines when usage data is null", async function () { + var ctx6 = makeCtx(); + setEnv(ctx6, API_KEY_ENV, "k"); + // No session key, so no usage detail data + mockUsageApi(ctx6, JSON.stringify({ usable_requests: 100, credits: 10 })); + var p6 = await loadPlugin(); + var result = p6.probe(ctx6); + var modelLines = result.lines.filter(function (l) { return /tokens/.test(l.value || ""); }); + expect(modelLines.length).toBe(0); + }); + + it("shows total tokens as 0 when usage data is empty object", async function () { + var ctx7 = makeCtx(); + setEnv(ctx7, API_KEY_ENV, "k"); + setEnv(ctx7, SESSION_KEY_ENV, "s"); + mockAllApis(ctx7, { usageDetailBody: "{}" }); + var p7 = await loadPlugin(); + var result = p7.probe(ctx7); + var totalLine = result.lines.find(function (l) { return l.label === "Total tokens"; }); + expect(totalLine.value).toBe("0"); + }); }); - it("sorts models descending", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "s"); - mockHttp(ctx, { userUsageBody: JSON.stringify({ a: { total_tokens: 100 }, b: { total_tokens: 500 } }) }); - var plugin = await loadPlugin(); - var models = plugin.probe(ctx).lines.slice(3); - expect(models[0].label).toBe("b"); - expect(models[1].label).toBe("a"); + // ── Token formatting ── + + describe("token formatting", function () { + it("formats 0 tokens as '0'", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 0 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("0"); + }); + + it("formats tokens under 1000 as raw number", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 999 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("999"); + }); + + it("formats thousands as K", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 1500 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.5K"); + }); + + it("formats millions as M", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 2500000 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("2.5M"); + }); + + it("formats billions as B", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 1200000000 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.2B"); + }); + + it("formats exact 1B correctly", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: { total_tokens: 1000000000 } }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0B"); + }); }); - // ---- Session key sources ---- + // ── Full happy path ── + + describe("happy path - full output", function () { + it("returns progress, credits, total tokens, and model lines with session key", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines.length).toBe(5); // progress + credits + total tokens + 2 models + expect(result.lines[0]).toMatchObject({ type: "progress", label: "Requests" }); + expect(result.lines[1]).toMatchObject({ type: "text", label: "Credits", value: "$42.50" }); + expect(result.lines[2]).toMatchObject({ type: "text", label: "Total tokens" }); + expect(result.lines[3].label).toBe("deepseek-v4-pro"); + expect(result.lines[4].label).toBe("kimi-k2.6"); + expect(result.plan).toBe("Intermediate"); + }); - it("reads session key from file", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setSessionKeyFile(ctx, "file-session"); - mockHttp(ctx); - var plugin = await loadPlugin(); - var result = plugin.probe(ctx); - expect(result.plan).toBe("Intermediate"); + it("returns progress and credits only without session key", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 500, credits: 25 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + + expect(result.lines.length).toBe(3); // progress + credits + total tokens (0) + expect(result.lines[0]).toMatchObject({ type: "progress" }); + expect(result.lines[1]).toMatchObject({ type: "text", label: "Credits" }); + expect(result.lines[2]).toMatchObject({ type: "text", label: "Total tokens", value: "0" }); + }); }); - it("env var takes priority over file", async () => { - var ctx = makeCtx(); - setEnv(ctx, API_KEY_ENV, "k"); - setEnv(ctx, SESSION_KEY_ENV, "env-session"); - setSessionKeyFile(ctx, "file-session"); - mockHttp(ctx); - var plugin = await loadPlugin(); - - var calls = ctx.host.http.request.mock.calls; - for (var i = 0; i < calls.length; i++) { - var opts = calls[i][0]; - if (opts.url === "https://crof.ai/pricing_api") { - expect(opts.headers.Cookie).toBe("session=env-session"); - } - } + // ── Error message matching ── + + describe("error messages", function () { + it("throws specific message for missing API key", async function () { + var ctx = makeCtx(); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI not configured"); + }); + + it("throws specific message for network error", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockImplementation(function () { throw new Error("refused"); }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI network error"); + }); + + it("throws specific message for auth errors", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI auth expired"); + }); + + it("throws specific message for server errors", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + ctx.host.http.request.mockReturnValue({ status: 502, headers: {}, bodyText: "Bad gateway" }); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Crof.AI API error (HTTP 502)"); + }); + + it("throws specific message for invalid response", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, "not-json"); + var plugin = await loadPlugin(); + expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); + }); }); }); From ea4562b5f58db2cb65fc9c715d3a8bdfe813e3cd Mon Sep 17 00:00:00 2001 From: "gabriele.palaj" Date: Sun, 10 May 2026 13:11:03 +0200 Subject: [PATCH 4/5] fix(crofai): type-check per-model token values before aggregation Model total_tokens/totalTokens are now required to be actual numbers via typeof check before aggregation into totalTokens and model list. Rejects strings, booleans, objects, Infinity, NaN, and negatives. Adds 7 tests for type edge cases. --- plugins/crofai/plugin.js | 8 ++-- plugins/crofai/plugin.test.js | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js index cf96f029..beb49f32 100644 --- a/plugins/crofai/plugin.js +++ b/plugins/crofai/plugin.js @@ -153,10 +153,10 @@ for (var key in usageData) { if (Object.prototype.hasOwnProperty.call(usageData, key)) { var model = usageData[key]; - var tt = model.total_tokens || model.totalTokens || 0; - if (tt > 0) { - totalTokens += tt; - models.push({ name: key, tokens: tt }); + var rawTt = model.total_tokens !== undefined ? model.total_tokens : model.totalTokens; + if (typeof rawTt === "number" && Number.isFinite(rawTt) && rawTt > 0) { + totalTokens += rawTt; + models.push({ name: key, tokens: rawTt }); } } } diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js index c4ade01c..ba2ea4cc 100644 --- a/plugins/crofai/plugin.test.js +++ b/plugins/crofai/plugin.test.js @@ -767,6 +767,95 @@ describe("crofai plugin", function () { expect(modelLine.value).toBe("3.0K tokens"); }); + it("skips models with string numeric total_tokens to avoid string concat", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: "5000" }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var totalLine = result.lines.find(function (l) { return l.label === "Total tokens"; }); + expect(totalLine.value).toBe("1.0K"); + expect(result.lines.find(function (l) { return l.label === "a"; })).toBeUndefined(); + }); + + it("skips models with boolean total_tokens", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: true }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var totalLine = result.lines.find(function (l) { return l.label === "Total tokens"; }); + expect(totalLine.value).toBe("1.0K"); + expect(result.lines.find(function (l) { return l.label === "a"; })).toBeUndefined(); + }); + + it("skips models with Infinity total_tokens", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: Infinity }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); + }); + + it("skips models with negative total_tokens", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: -500 }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); + }); + + it("skips models with object total_tokens", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: { x: 1 } }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); + }); + + it("skips models with non-numeric string total_tokens", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: "abc" }, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); + }); + + it("prefers total_tokens over totalTokens when both present", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: 2000, totalTokens: 999 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var modelLine = result.lines.find(function (l) { return l.label === "a"; }); + expect(modelLine.value).toBe("2.0K tokens"); + }); + it("shows no model lines when usage data is null", async function () { var ctx6 = makeCtx(); setEnv(ctx6, API_KEY_ENV, "k"); From f4fc0fb6684dd0151bf471d7e2e4fdc6aef15e38 Mon Sep 17 00:00:00 2001 From: "gabriele.palaj" Date: Wed, 13 May 2026 21:19:17 +0200 Subject: [PATCH 5/5] fix(crofai): guard null/array/missing edge cases, fix Accept header and vitest pool - Wrap fs.exists() in try-catch to prevent crash on filesystem errors - Move Accept header into headers object (was top-level, silently ignored) - Guard resp.bodyText against null/undefined before tryParseJson - Guard formatTokens against undefined/null/NaN (was showing 'undefined') - Add Array.isArray check on usageData to prevent array-as-object iteration - Guard null model entries (total_tokens on null throws TypeError) - Use && !== null check on total_tokens to fall through to totalTokens - Replace in operator with hasOwnProperty to avoid prototype pollution - Handle credits: null as 'no data' instead of crashing or showing /bin/bash.00 - Guard null >= 0 JS coercion in credits display - Add pool: forks to vitest config (fixes node:fs in jsdom for all plugins) - Add 3 tests: credits null, total_tokens null fallback, formatTokens guard - Add 2 tests: no resetsAt/periodDurationMs on progress line - Add 2 tests: null model entries skip, array response skipped --- plugins/crofai/plugin.js | 91 ++++++++++++++++++++++++++--------- plugins/crofai/plugin.test.js | 82 ++++++++++++++++++++++++++++++- vite.config.ts | 1 + 3 files changed, 151 insertions(+), 23 deletions(-) diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js index beb49f32..bcccf95b 100644 --- a/plugins/crofai/plugin.js +++ b/plugins/crofai/plugin.js @@ -33,7 +33,11 @@ } var keyPath = ctx.app.pluginDataDir + "/session-key"; - if (!ctx.host.fs.exists(keyPath)) { + try { + if (!ctx.host.fs.exists(keyPath)) { + return null; + } + } catch (e) { return null; } try { @@ -50,8 +54,7 @@ resp = ctx.host.http.request({ method: "GET", url: opts.url, - headers: opts.headers || {}, - Accept: "application/json", + headers: Object.assign({ Accept: "application/json" }, opts.headers || {}), timeoutMs: TIMEOUT_MS, }); } catch (e) { @@ -66,6 +69,9 @@ throw "Crof.AI API error (HTTP " + resp.status + "). Try again later."; } + if (resp.bodyText == null || typeof resp.bodyText !== "string") { + throw "Invalid response from Crof.AI. Try again later."; + } var parsed = ctx.util.tryParseJson(resp.bodyText); if (parsed === null) { throw "Invalid response from Crof.AI. Try again later."; @@ -80,6 +86,7 @@ } function formatTokens(count) { + if (typeof count !== "number" || !Number.isFinite(count)) return "0"; if (count >= 1e9) { var b = count / 1e9; return (Math.round(b * 10) / 10).toFixed(1) + "B"; @@ -104,7 +111,14 @@ return sign + "$" + abs.toFixed(2); } - function buildLines(ctx, usageData, requestsCount, creditsValue, planInfo, usagePlanRequests) { + function buildLines( + ctx, + usageData, + requestsCount, + creditsValue, + planInfo, + usagePlanRequests, + ) { var lines = []; var maxRequests = FALLBACK_MAX_REQUESTS; @@ -134,27 +148,35 @@ used: used, limit: maxRequests, format: { kind: "count", suffix: "requests" }, - }) + }), ); - if (creditsValue >= 0) { + if (creditsValue !== null && creditsValue >= 0) { lines.push( ctx.line.text({ label: "Credits", value: formatCredits(creditsValue), - }) + }), ); } var models = []; var totalTokens = 0; - if (usageData && typeof usageData === "object") { + if (usageData && typeof usageData === "object" && !Array.isArray(usageData)) { for (var key in usageData) { if (Object.prototype.hasOwnProperty.call(usageData, key)) { var model = usageData[key]; - var rawTt = model.total_tokens !== undefined ? model.total_tokens : model.totalTokens; - if (typeof rawTt === "number" && Number.isFinite(rawTt) && rawTt > 0) { + if (!model || typeof model !== "object") continue; + var rawTt = + model.total_tokens !== undefined && model.total_tokens !== null + ? model.total_tokens + : model.totalTokens; + if ( + typeof rawTt === "number" && + Number.isFinite(rawTt) && + rawTt > 0 + ) { totalTokens += rawTt; models.push({ name: key, tokens: rawTt }); } @@ -167,7 +189,7 @@ label: "Total tokens", value: formatTokens(totalTokens), subtitle: models.length > 0 ? models.length + " models" : undefined, - }) + }), ); models.sort(function (a, b) { @@ -181,7 +203,7 @@ ctx.line.text({ label: m.name, value: formatTokens(m.tokens) + " tokens", - }) + }), ); } @@ -198,24 +220,41 @@ }, }); - if (!usageResp || typeof usageResp !== "object" || Array.isArray(usageResp)) { + if ( + !usageResp || + typeof usageResp !== "object" || + Array.isArray(usageResp) + ) { throw "Invalid response from Crof.AI. Try again later."; } - var hasCredits = "credits" in usageResp; - var creditsValue = 0; + var hasCredits = Object.prototype.hasOwnProperty.call(usageResp, "credits"); + var creditsValue = null; if (hasCredits) { - if (typeof usageResp.credits !== "number" || !Number.isFinite(usageResp.credits)) { + if (usageResp.credits === null) { + creditsValue = null; + } else if ( + typeof usageResp.credits !== "number" || + !Number.isFinite(usageResp.credits) + ) { throw "Invalid response from Crof.AI. Try again later."; + } else { + creditsValue = usageResp.credits; } - creditsValue = usageResp.credits; + } + // Normalize: missing field → show as $0.00, null → skip entirely + if (creditsValue === null && usageResp.credits !== null) { + creditsValue = 0; } var requestsCount = null; - var hasRequests = "usable_requests" in usageResp; + var hasRequests = Object.prototype.hasOwnProperty.call(usageResp, "usable_requests"); if (hasRequests) { if (usageResp.usable_requests !== null) { - if (typeof usageResp.usable_requests !== "number" || !Number.isFinite(usageResp.usable_requests)) { + if ( + typeof usageResp.usable_requests !== "number" || + !Number.isFinite(usageResp.usable_requests) + ) { throw "Invalid response from Crof.AI. Try again later."; } requestsCount = usageResp.usable_requests; @@ -223,7 +262,7 @@ } var usagePlanRequests = null; - if ("requests_plan" in usageResp) { + if (Object.prototype.hasOwnProperty.call(usageResp, "requests_plan")) { if ( typeof usageResp.requests_plan === "number" && Number.isFinite(usageResp.requests_plan) && @@ -259,7 +298,8 @@ }, }); if (planInfo && planInfo.name) { - planName = planFullName(planInfo.name) || ctx.fmt.planLabel(planInfo.name); + planName = + planFullName(planInfo.name) || ctx.fmt.planLabel(planInfo.name); } } catch (e) { ctx.host.log.warn("Crof.AI pricing fetch failed: " + String(e)); @@ -268,7 +308,14 @@ return { plan: planName, - lines: buildLines(ctx, userUsageData, requestsCount, creditsValue, planInfo, usagePlanRequests), + lines: buildLines( + ctx, + userUsageData, + requestsCount, + creditsValue, + planInfo, + usagePlanRequests, + ), }; } diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js index ba2ea4cc..3ff5239b 100644 --- a/plugins/crofai/plugin.test.js +++ b/plugins/crofai/plugin.test.js @@ -133,7 +133,7 @@ describe("crofai plugin", function () { expect(call.method).toBe("GET"); expect(call.url).toBe(USAGE_API_URL); expect(call.headers.Authorization).toBe("Bearer test-api-key"); - expect(call.Accept).toBe("application/json"); + expect(call.headers.Accept).toBe("application/json"); expect(call.timeoutMs).toBe(10000); }); }); @@ -284,6 +284,15 @@ describe("crofai plugin", function () { var plugin = await loadPlugin(); expect(function () { plugin.probe(ctx); }).toThrow("Invalid response from Crof.AI"); }); + + it("credits null does not crash, treats as missing", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ credits: null, usable_requests: 10 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Credits"; })).toBeUndefined(); + }); }); // ── Response validation: usable_requests field ── @@ -396,6 +405,28 @@ describe("crofai plugin", function () { }); }); + // ── Progress bar: no fake reset time ── + + describe("progress bar - no resetsAt or periodDurationMs", function () { + it("does not include resetsAt on progress line", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 10 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0].resetsAt).toBeUndefined(); + }); + + it("does not include periodDurationMs on progress line", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + mockUsageApi(ctx, JSON.stringify({ usable_requests: 100, credits: 10 })); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines[0].periodDurationMs).toBeUndefined(); + }); + }); + // ── Progress bar: fallback on session-dependent API failures ── describe("progress bar - fallback on failures", function () { @@ -831,6 +862,32 @@ describe("crofai plugin", function () { expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); }); + it("skips null model entries without crashing", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: null, b: { total_tokens: 1000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0K"); + expect(result.lines.find(function (l) { return l.label === "a"; })).toBeUndefined(); + }); + + it("skips array response from user usage API", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify([{ total_tokens: 5000 }, { total_tokens: 3000 }]), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("0"); + expect(result.lines.find(function (l) { return l.label === "0"; })).toBeUndefined(); + }); + it("skips models with non-numeric string total_tokens", async function () { var ctx = makeCtx(); setEnv(ctx, API_KEY_ENV, "k"); @@ -856,6 +913,19 @@ describe("crofai plugin", function () { expect(modelLine.value).toBe("2.0K tokens"); }); + it("falls back to totalTokens when total_tokens is null", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { + usageDetailBody: JSON.stringify({ a: { total_tokens: null, totalTokens: 3000 } }), + }); + var p = await loadPlugin(); + var result = p.probe(ctx); + var modelLine = result.lines.find(function (l) { return l.label === "a"; }); + expect(modelLine.value).toBe("3.0K tokens"); + }); + it("shows no model lines when usage data is null", async function () { var ctx6 = makeCtx(); setEnv(ctx6, API_KEY_ENV, "k"); @@ -941,6 +1011,16 @@ describe("crofai plugin", function () { var result = plugin.probe(ctx); expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("1.0B"); }); + + it("formats missing total_tokens as 0 not 'undefined'", async function () { + var ctx = makeCtx(); + setEnv(ctx, API_KEY_ENV, "k"); + setEnv(ctx, SESSION_KEY_ENV, "s"); + mockAllApis(ctx, { usageDetailBody: JSON.stringify({ a: {} }) }); + var plugin = await loadPlugin(); + var result = plugin.probe(ctx); + expect(result.lines.find(function (l) { return l.label === "Total tokens"; }).value).toBe("0"); + }); }); // ── Full happy path ── diff --git a/vite.config.ts b/vite.config.ts index fb266892..b8fbafdd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig(async () => ({ }, test: { + pool: "forks", environment: "jsdom", setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.test.{ts,tsx}", "plugins/**/*.test.js"],