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..f2eb1490 --- /dev/null +++ b/plugins/crofai/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js new file mode 100644 index 00000000..bcccf95b --- /dev/null +++ b/plugins/crofai/plugin.js @@ -0,0 +1,323 @@ +(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"; + try { + if (!ctx.host.fs.exists(keyPath)) { + return null; + } + } catch (e) { + 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: Object.assign({ Accept: "application/json" }, opts.headers || {}), + 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."; + } + + 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."; + } + return parsed; + } + + function planFullName(raw) { + if (!raw) return null; + var lower = String(raw).toLowerCase().trim(); + return PLAN_NAMES[lower] || null; + } + + 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"; + } + 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, + usagePlanRequests, + ) { + var lines = []; + + var maxRequests = FALLBACK_MAX_REQUESTS; + if ( + planInfo && + typeof planInfo.requests === "number" && + planInfo.requests > 0 + ) { + maxRequests = planInfo.requests; + } else if ( + typeof usagePlanRequests === "number" && + Number.isFinite(usagePlanRequests) && + usagePlanRequests > 0 + ) { + maxRequests = usagePlanRequests; + } + + 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" }, + }), + ); + + 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" && !Array.isArray(usageData)) { + for (var key in usageData) { + if (Object.prototype.hasOwnProperty.call(usageData, key)) { + var model = usageData[key]; + 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 }); + } + } + } + } + + 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 = Object.prototype.hasOwnProperty.call(usageResp, "credits"); + var creditsValue = null; + if (hasCredits) { + 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; + } + } + // Normalize: missing field → show as $0.00, null → skip entirely + if (creditsValue === null && usageResp.credits !== null) { + creditsValue = 0; + } + + var requestsCount = null; + 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) + ) { + throw "Invalid response from Crof.AI. Try again later."; + } + requestsCount = usageResp.usable_requests; + } + } + + var usagePlanRequests = null; + if (Object.prototype.hasOwnProperty.call(usageResp, "requests_plan")) { + 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; + 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, + usagePlanRequests, + ), + }; + } + + 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..3ff5239b --- /dev/null +++ b/plugins/crofai/plugin.test.js @@ -0,0 +1,1101 @@ +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeCtx } from "../test-helpers.js"; + +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; +}; + +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 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", function () { + beforeEach(function () { + delete globalThis.__openusage_plugin; + vi.resetModules(); + }); + + afterEach(function () { + vi.restoreAllMocks(); + }); + + // ── 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.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: API key validation ── + + 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 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 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"); + }); + + 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 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"); + }); + + 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.headers.Accept).toBe("application/json"); + expect(call.timeoutMs).toBe(10000); + }); + }); + + // ── 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"); + }); + + 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"); + }); + + 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"); + }); + + 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)"); + }); + + 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)"); + }); + + 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)"); + }); + }); + + // ── 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"); + }); + + 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("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"); + }); + + 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"); + }); + + 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("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"); + }); + }); + + // ── Response validation: credits field ── + + 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("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("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("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 ── + + 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 }); + }); + }); + + // ── 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: 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 () { + 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("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(); + }); + + 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"); + }); + }); + + // ── 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(); + }); + + 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"); + }); + }); + + // ── 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"); + }); + + 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(); + }); + }); + + // ── 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"); + } + } + }); + + 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(); + }); + + 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("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(); + }); + + 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"); + }); + }); + + // ── 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 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("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 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"); + 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("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"); + // 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"); + }); + }); + + // ── 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"); + }); + + 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 ── + + 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("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" }); + }); + }); + + // ── 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"); + }); + }); +}); 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 { 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"],