From 8c4f9440836e4008b1b780c10ba96e8dde8bb6bd Mon Sep 17 00:00:00 2001 From: Sadik <118531001+msadiks@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:08:55 +0300 Subject: [PATCH] feat(crofai): add CrofAI provider --- README.md | 1 + docs/providers/crofai.md | 62 ++++ plugins/crofai/icon.svg | 16 + plugins/crofai/plugin.js | 81 +++++ plugins/crofai/plugin.json | 16 + plugins/crofai/plugin.test.js | 379 ++++++++++++++++++++++++ src-tauri/src/plugin_engine/host_api.rs | 3 +- 7 files changed, 557 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 1bcdda59..9902c460 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Claude**](docs/providers/claude.md) / session, weekly, peak/off-peak, extra usage, local token usage (ccusage) - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions +- [**CrofAI**](docs/providers/crofai.md) / credits, daily requests - [**Cursor**](docs/providers/cursor.md) / credits, total usage, auto usage, API usage, on-demand, CLI auth - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens - [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier diff --git a/docs/providers/crofai.md b/docs/providers/crofai.md new file mode 100644 index 00000000..55536fdb --- /dev/null +++ b/docs/providers/crofai.md @@ -0,0 +1,62 @@ +# CrofAI + +> Uses the CrofAI Usage API with a user-provided API key. + +## Overview + +- **Protocol:** HTTPS (JSON) +- **Endpoint:** `GET https://crof.ai/usage_api/` +- **Auth:** `Authorization: Bearer ` +- **Data:** credit balance, daily requests remaining + +## Authentication + +Set the `CROFAI_API_KEY` environment variable: + +```bash +export CROFAI_API_KEY="your-api-key-here" +``` + +The key is read from the environment at probe time. Restart OpenUsage after setting it. + +## Data Source + +Request: + +```http +GET /usage_api/ HTTP/1.1 +Host: crof.ai +Authorization: Bearer +Accept: application/json +``` + +Response: + +```json +{ + "credits": 12.3456, + "usable_requests": 321, + "requests_plan": 500 +} +``` + +| Field | Type | Description | +|---|---|---| +| `credits` | number | Available credit balance (USD) | +| `usable_requests` | number \| null | Requests remaining today (`null` if not on a subscription plan) | +| `requests_plan` | number | Total daily request limit | + +## Output + +- **Requests** (overview progress line): progress bar showing used requests out of the daily plan (e.g., `179 / 500`); hidden when `usable_requests` is `null` +- **Credits** (overview text line): formatted dollar balance (e.g., `$12.35`) + +## Errors + +| Condition | Message | +|---|---| +| Missing `CROFAI_API_KEY` env var | `No CROFAI_API_KEY found. Set up environment variable first.` | +| HTTP 401/403 | `API key invalid. Check your CrofAI API key.` | +| Non-2xx | `Usage request failed (HTTP {status}). Try again later.` | +| Network failure | `Usage request failed. Check your connection.` | +| Unparseable or invalid response shape/type | `Usage response invalid. Try again later.` | diff --git a/plugins/crofai/icon.svg b/plugins/crofai/icon.svg new file mode 100644 index 00000000..265fbc33 --- /dev/null +++ b/plugins/crofai/icon.svg @@ -0,0 +1,16 @@ + + + diff --git a/plugins/crofai/plugin.js b/plugins/crofai/plugin.js new file mode 100644 index 00000000..403f49a8 --- /dev/null +++ b/plugins/crofai/plugin.js @@ -0,0 +1,81 @@ +(function () { + var API_URL = "https://crof.ai/usage_api/" + + function probe(ctx) { + var apiKey = ctx.host.env.get("CROFAI_API_KEY") + if (!apiKey || !String(apiKey).trim()) { + throw "No CROFAI_API_KEY found. Set up environment variable first." + } + + var resp + try { + resp = ctx.util.request({ + method: "GET", + url: API_URL, + headers: { + Authorization: "Bearer " + String(apiKey).trim(), + Accept: "application/json", + }, + timeoutMs: 10000, + }) + } catch (e) { + throw "Usage request failed. Check your connection." + } + + if (ctx.util.isAuthStatus(resp.status)) { + throw "API key invalid. Check your CrofAI API key." + } + + if (resp.status < 200 || resp.status >= 300) { + throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." + } + + var data = ctx.util.tryParseJson(resp.bodyText) + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw "Usage response invalid. Try again later." + } + + var hasCredits = typeof data.credits === "number" && Number.isFinite(data.credits) + if ("credits" in data && !hasCredits) { + throw "Usage response invalid. Try again later." + } + var credits = hasCredits ? data.credits : 0 + + var lines = [] + + var usableRequests = data.usable_requests + if (usableRequests !== null && usableRequests !== undefined) { + if (typeof usableRequests !== "number" || !Number.isFinite(usableRequests)) { + throw "Usage response invalid. Try again later." + } + + var requestsPlan = data.requests_plan + if (typeof requestsPlan !== "number" || !Number.isFinite(requestsPlan) || requestsPlan <= 0) { + throw "Usage response invalid. Try again later." + } + + var usedRequests = Math.max(0, requestsPlan - usableRequests) + lines.push( + ctx.line.progress({ + label: "Requests", + used: usedRequests, + limit: requestsPlan, + format: { kind: "count", suffix: "requests" }, + }) + ) + } + + if (hasCredits && credits >= 0) { + lines.push( + ctx.line.text({ + label: "Credits", + value: "$" + credits.toFixed(2), + }) + ) + } + + return { lines: lines } + } + + globalThis.__openusage_plugin = { id: "crofai", probe } +})() diff --git a/plugins/crofai/plugin.json b/plugins/crofai/plugin.json new file mode 100644 index 00000000..fdb6f175 --- /dev/null +++ b/plugins/crofai/plugin.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "crofai", + "name": "CrofAI", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "links": [ + { "label": "Status", "url": "https://status.nahcrof.com" } + ], + "lines": [ + { "type": "progress", "label": "Requests", "scope": "overview" }, + { "type": "text", "label": "Credits", "scope": "overview" } + ] +} diff --git a/plugins/crofai/plugin.test.js b/plugins/crofai/plugin.test.js new file mode 100644 index 00000000..8319322d --- /dev/null +++ b/plugins/crofai/plugin.test.js @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const API_URL = "https://crof.ai/usage_api/" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +function makeSuccessResponse(credits, usableRequests, requestsPlan = 500) { + const body = { credits: credits } + if (usableRequests !== undefined) { + body.usable_requests = usableRequests + } + if (requestsPlan !== undefined) { + body.requests_plan = requestsPlan + } + return { status: 200, bodyText: JSON.stringify(body) } +} + +describe("crofai plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws when CROFAI_API_KEY is missing", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "No CROFAI_API_KEY found. Set up environment variable first." + ) + }) + + it("throws when CROFAI_API_KEY is empty", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("") + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "No CROFAI_API_KEY found. Set up environment variable first." + ) + }) + + it("throws when CROFAI_API_KEY is whitespace", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue(" ") + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "No CROFAI_API_KEY found. Set up environment variable first." + ) + }) + + it("sends GET with Bearer auth to correct URL", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(10, 100)) + const plugin = await loadPlugin() + plugin.probe(ctx) + + const call = ctx.host.http.request.mock.calls[0][0] + expect(call.method).toBe("GET") + expect(call.url).toBe(API_URL) + expect(call.headers.Authorization).toBe("Bearer test-api-key") + expect(call.headers.Accept).toBe("application/json") + expect(call.timeoutMs).toBe(10000) + }) + + it("throws on network error", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockImplementation(() => { + throw new Error("ECONNREFUSED") + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage request failed. Check your connection." + ) + }) + + it("throws on HTTP 401", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "API key invalid. Check your CrofAI API key." + ) + }) + + it("throws on HTTP 403", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 403, bodyText: "" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "API key invalid. Check your CrofAI API key." + ) + }) + + it("throws on HTTP 500", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage request failed (HTTP 500). Try again later." + ) + }) + + it("throws on HTTP 300 (non-2xx boundary)", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 300, bodyText: "" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage request failed (HTTP 300). Try again later." + ) + }) + + it("throws on unparseable JSON", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: "not-json" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws on null bodyText", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: null }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws on empty bodyText", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: "" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when response body is an array", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: JSON.stringify([1, 2]) }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when response body contains Infinity", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: '{"credits":Infinity,"usable_requests":10,"requests_plan":500}', + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when response body contains NaN", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: '{"credits":NaN,"usable_requests":10,"requests_plan":500}', + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when usable_requests is a boolean", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 10, usable_requests: true, requests_plan: 500 }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("returns credits and requests progress lines", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(12.3456, 321, 500)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(2) + + const requestsLine = result.lines[0] + expect(requestsLine.type).toBe("progress") + expect(requestsLine.label).toBe("Requests") + expect(requestsLine.used).toBe(179) + expect(requestsLine.limit).toBe(500) + expect(requestsLine.format).toEqual({ kind: "count", suffix: "requests" }) + + const creditsLine = result.lines[1] + expect(creditsLine.type).toBe("text") + expect(creditsLine.label).toBe("Credits") + expect(creditsLine.value).toBe("$12.35") + }) + + it("shows credits line as $0.00 when credits is zero", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(0, 100)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const creditsLine = result.lines.find((l) => l.label === "Credits") + expect(creditsLine).toBeDefined() + expect(creditsLine.value).toBe("$0.00") + expect(result.lines.length).toBe(2) + }) + + it("omits credits line when credits is negative", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(-5, 100)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((l) => l.label === "Credits")).toBeUndefined() + }) + + it("omits requests line when usable_requests is null", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(5, null)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Credits") + }) + + it("omits requests line when usable_requests is absent", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 5, requests_plan: 500 }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Credits") + }) + + it("throws when requests_plan is absent", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 5, usable_requests: 10 }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("omits requests line when request fields are absent", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 5 }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Credits") + }) + + it("clamps used requests to zero when usable_requests exceeds requests_plan", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(5, 200, 100)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(2) + const requestsLine = result.lines[0] + expect(requestsLine.type).toBe("progress") + expect(requestsLine.label).toBe("Requests") + expect(requestsLine.used).toBe(0) + expect(requestsLine.limit).toBe(100) + }) + + it("shows progress when usable_requests is negative", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue(makeSuccessResponse(5, -5, 500)) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBe(2) + const requestsLine = result.lines[0] + expect(requestsLine.type).toBe("progress") + expect(requestsLine.label).toBe("Requests") + expect(requestsLine.used).toBe(505) + expect(requestsLine.limit).toBe(500) + }) + + it("throws when usable_requests is a string", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 10, usable_requests: "50", requests_plan: 500 }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when requests_plan is invalid", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: 10, usable_requests: 10, requests_plan: 0 }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("throws when credits is non-numeric", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ credits: "abc", usable_requests: 10, requests_plan: 500 }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow( + "Usage response invalid. Try again later." + ) + }) + + it("omits credits line when credits field is missing", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockReturnValue("test-api-key") + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ usable_requests: 10, requests_plan: 500 }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((l) => l.label === "Credits")).toBeUndefined() + expect(result.lines[0].label).toBe("Requests") + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 2b671f90..bd59cb5b 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -11,10 +11,11 @@ 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; 17] = [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", + "CROFAI_API_KEY", "USER_TYPE", "USE_STAGING_OAUTH", "USE_LOCAL_OAUTH",