From 8031849b557339e3212a614065e6db7b6385a29b Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Mon, 30 Mar 2026 12:19:31 -0600 Subject: [PATCH 1/2] feat: add Synthetic provider plugin Add a new provider plugin for Synthetic (synthetic.new) that displays quota usage from their /v2/quotas API endpoint. Supports both the new v3 rate limit system (rolling 5h request limit and weekly mana bar) and the legacy subscription based quota, showing whichever is relevant to the user's account. Search quota is shown for all users. API key discovery checks multiple sources in order: Pi auth.json, Pi models.json, Factory/Droid settings.json, OpenCode auth.json, and the SYNTHETIC_API_KEY env var. Two new env vars are whitelisted in the Rust host (SYNTHETIC_API_KEY and PI_CODING_AGENT_DIR). Icon sourced from synthetic.new/favicon.svg. Brand color is black to match their branding. Files added: - plugins/synthetic/plugin.json - plugins/synthetic/plugin.js - plugins/synthetic/plugin.test.js (52 tests) - plugins/synthetic/icon.svg - docs/providers/synthetic.md Files modified: - src-tauri/src/plugin_engine/host_api.rs (env var whitelist) --- docs/providers/synthetic.md | 165 ++++++ plugins/synthetic/icon.svg | 12 + plugins/synthetic/plugin.js | 231 ++++++++ plugins/synthetic/plugin.json | 20 + plugins/synthetic/plugin.test.js | 752 ++++++++++++++++++++++++ src-tauri/src/plugin_engine/host_api.rs | 4 +- 6 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 docs/providers/synthetic.md create mode 100644 plugins/synthetic/icon.svg create mode 100644 plugins/synthetic/plugin.js create mode 100644 plugins/synthetic/plugin.json create mode 100644 plugins/synthetic/plugin.test.js diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md new file mode 100644 index 00000000..64fba4e6 --- /dev/null +++ b/docs/providers/synthetic.md @@ -0,0 +1,165 @@ +# Synthetic + +## Overview + +- **Protocol:** REST (`GET /v2/quotas`) +- **URL:** `https://api.synthetic.new/v2/quotas` +- **Auth:** API key discovered from Pi, Factory/Droid, OpenCode, or `SYNTHETIC_API_KEY` env var +- **Tier:** Subscription packs ($30/month base) with rolling rate limits + +## Authentication + +The plugin searches multiple sources for a Synthetic API key, checking under the provider names `synthetic`, `synthetic.new`, and `syn`. The first key found wins. + +### Credential Sources (checked in order) + +**1. Pi auth.json** — `~/.pi/agent/auth.json` + +```json +{ + "synthetic": { + "type": "api_key", + "key": "syn_..." + } +} +``` + +The Pi agent directory can be overridden via the `PI_CODING_AGENT_DIR` environment variable (e.g. `PI_CODING_AGENT_DIR=~/custom/pi`). + +**2. Pi models.json** — `~/.pi/agent/models.json` + +For users who configured Synthetic as a custom provider in Pi: + +```json +{ + "providers": { + "synthetic": { + "apiKey": "syn_..." + } + } +} +``` + +**3. Factory/Droid settings.json** — `~/.factory/settings.json` + +For users who configured Synthetic as a custom model in Factory/Droid. The plugin scans the `customModels` array for any entry with a `baseUrl` containing `synthetic.new`: + +```json +{ + "customModels": [ + { + "baseUrl": "https://api.synthetic.new/openai/v1", + "apiKey": "syn_...", + "displayName": "Kimi K2.5 [Synthetic]" + } + ] +} +``` + +**4. OpenCode auth.json** — `~/.local/share/opencode/auth.json` + +```json +{ + "synthetic": { + "key": "syn_..." + } +} +``` + +**5. Environment variable** — `SYNTHETIC_API_KEY` + +Falls back to the `SYNTHETIC_API_KEY` environment variable if no file source contains a key. + +The key is sent as `Authorization: Bearer ` to the quotas API. + +## Data Source + +### API Endpoint + +``` +GET https://api.synthetic.new/v2/quotas +Authorization: Bearer +Accept: application/json +``` + +Quota checks do not count against subscription limits. + +### Response + +```json +{ + "subscription": { + "limit": 600, + "requests": 0, + "renewsAt": "2026-04-30T20:18:54.144Z" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 0, + "renewsAt": "2026-03-30T16:18:54.145Z" + } + }, + "weeklyTokenLimit": { + "nextRegenAt": "2026-03-30T16:20:39.000Z", + "percentRemaining": 100 + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-03-30T15:30:29.000Z", + "tickPercent": 0.05, + "remaining": 600, + "max": 600, + "limited": false + } +} +``` + +### Quota Systems + +Synthetic uses two complementary rate limiting systems: + +**Rolling 5-hour limit** — burst rate control: +- `remaining` / `max` requests in a rolling 5-hour window +- Every ~15 minutes, 5% of `max` is restored (a "tick") +- `limited` is `true` when `remaining` hits 0 +- `max` varies by subscription level (e.g. 400 standard, 600 founder's pack) + +**Weekly mana bar** — longer-term budget: +- A single quota that scales by token costs and cache hits (cache hits discounted 80%) +- Regenerates 2% every ~3.36 hours (full regen in one week) +- `percentRemaining` (0–100) tracks how much budget remains + +**Subscription** — legacy request count per billing period. + +**Search** — separate hourly quota for search requests. + +## Plan Detection + +No plan name is returned by the API. The plugin does not set a plan label. + +## Displayed Lines + +| Line | Scope | Condition | Description | +|----------------|----------|----------------------------------|------------------------------------------------| +| 5h Rate Limit | overview | `rollingFiveHourLimit` present | Requests remaining in 5-hour rolling window | +| Mana Bar | overview | `weeklyTokenLimit` present | Weekly token budget as percentage | +| Rate Limited | detail | `rollingFiveHourLimit.limited` | Red badge shown only when actively rate limited| +| Subscription | detail | `subscription` present | Legacy request count for billing period | +| Search | detail | `search.hourly` present | Hourly search request quota | + +5h Rate Limit is the primary (tray icon) metric — it's the first constraint users hit during active use. + +Progress lines include: +- `resetsAt` — ISO timestamp of next restoration tick (5h, mana) or renewal (subscription, search) +- `periodDurationMs` — 5 hours (rate limit), 1 week (mana), or 1 hour (search) + +## Errors + +| Condition | Message | +|------------------------|---------------------------------------------------------------------------| +| No API key found | "Synthetic API key not found. Set SYNTHETIC_API_KEY or add key to ~/.pi/agent/auth.json" | +| 401/403 | "API key invalid or expired. Check your Synthetic API key." | +| Non-2xx with detail | Error message from API response | +| Non-2xx without detail | "Request failed (HTTP {status})" | +| Unparseable response | "Could not parse usage data." | +| Network error | "Request failed. Check your connection." | diff --git a/plugins/synthetic/icon.svg b/plugins/synthetic/icon.svg new file mode 100644 index 00000000..8d82ce7c --- /dev/null +++ b/plugins/synthetic/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/plugins/synthetic/plugin.js b/plugins/synthetic/plugin.js new file mode 100644 index 00000000..91970482 --- /dev/null +++ b/plugins/synthetic/plugin.js @@ -0,0 +1,231 @@ +(function () { + var API_URL = "https://api.synthetic.new/v2/quotas"; + var ONE_HOUR_MS = 60 * 60 * 1000; + + var DEFAULT_PI_AGENT_DIR = "~/.pi/agent"; + var FACTORY_SETTINGS_PATH = "~/.factory/settings.json"; + var OPENCODE_AUTH_PATH = "~/.local/share/opencode/auth.json"; + + // Provider names a user might register Synthetic under in various harnesses + var PROVIDER_NAMES = ["synthetic", "synthetic.new", "syn"]; + + function resolvePiAgentDir(ctx) { + var envDir = ctx.host.env.get("PI_CODING_AGENT_DIR"); + if (typeof envDir === "string" && envDir.trim()) { + return envDir.trim(); + } + return DEFAULT_PI_AGENT_DIR; + } + + function extractKey(value) { + if (typeof value === "string" && value.trim()) return value.trim(); + return null; + } + + // Search a parsed JSON object for a Synthetic API key under known provider names + function findKeyInProviderMap(obj) { + if (!obj || typeof obj !== "object") return null; + for (var i = 0; i < PROVIDER_NAMES.length; i++) { + var entry = obj[PROVIDER_NAMES[i]]; + if (!entry) continue; + // Pi auth.json style: { "synthetic": { "type": "api_key", "key": "syn_..." } } + var k = extractKey(entry.key); + if (k) return k; + // Pi models.json style: { "providers": { "synthetic": { "apiKey": "syn_..." } } } + k = extractKey(entry.apiKey); + if (k) return k; + } + return null; + } + + function tryReadJson(ctx, path) { + try { + if (!ctx.host.fs.exists(path)) return null; + return ctx.util.tryParseJson(ctx.host.fs.readText(path)); + } catch (e) { + ctx.host.log.warn("Failed to read " + path + ": " + e); + return null; + } + } + + function loadApiKey(ctx) { + var piDir = resolvePiAgentDir(ctx); + + // 1. Pi auth.json — primary source + var piAuth = tryReadJson(ctx, piDir + "/auth.json"); + var key = findKeyInProviderMap(piAuth); + if (key) return key; + + // 2. Pi models.json — custom provider config with apiKey field + var piModels = tryReadJson(ctx, piDir + "/models.json"); + if (piModels && piModels.providers) { + key = findKeyInProviderMap(piModels.providers); + if (key) return key; + } + + // 3. Factory/Droid settings.json — custom models with synthetic.new baseUrl + var factorySettings = tryReadJson(ctx, FACTORY_SETTINGS_PATH); + if (factorySettings && Array.isArray(factorySettings.customModels)) { + for (var i = 0; i < factorySettings.customModels.length; i++) { + var model = factorySettings.customModels[i]; + if ( + model && + typeof model.baseUrl === "string" && + model.baseUrl.indexOf("synthetic.new") !== -1 + ) { + key = extractKey(model.apiKey); + if (key) return key; + } + } + } + + // 4. OpenCode auth.json + var ocAuth = tryReadJson(ctx, OPENCODE_AUTH_PATH); + key = findKeyInProviderMap(ocAuth); + if (key) return key; + + // 5. SYNTHETIC_API_KEY env var + var envKey = ctx.host.env.get("SYNTHETIC_API_KEY"); + if (typeof envKey === "string" && envKey.trim()) { + return envKey.trim(); + } + + return null; + } + + function probe(ctx) { + var apiKey = loadApiKey(ctx); + if (!apiKey) { + throw "Synthetic API key not found. Set SYNTHETIC_API_KEY or add key to ~/.pi/agent/auth.json"; + } + + var resp, json; + try { + var result = ctx.util.requestJson({ + method: "GET", + url: API_URL, + headers: { + Authorization: "Bearer " + apiKey, + Accept: "application/json", + }, + timeoutMs: 15000, + }); + resp = result.resp; + json = result.json; + } catch (e) { + throw "Request failed. Check your connection."; + } + + if (ctx.util.isAuthStatus(resp.status)) { + throw "API key invalid or expired. Check your Synthetic API key."; + } + + if (resp.status < 200 || resp.status >= 300) { + var msg = + json && json.error ? json.error : "Request failed (HTTP " + resp.status + ")"; + throw msg; + } + + if (!json) { + throw "Could not parse usage data."; + } + + var lines = []; + + // 5h Rate Limit — hero metric (immediate blocker) + if ( + json.rollingFiveHourLimit && + typeof json.rollingFiveHourLimit.max === "number" + ) { + var rfl = json.rollingFiveHourLimit; + var rflUsed = Math.max(0, rfl.max - rfl.remaining); + lines.push(ctx.line.progress({ + label: "5h Rate Limit", + used: rflUsed, + limit: rfl.max, + format: { kind: "count", suffix: "requests" }, + })); + } + + // Mana Bar — longer-term weekly budget + if ( + json.weeklyTokenLimit && + typeof json.weeklyTokenLimit.percentRemaining === "number" + ) { + var pct = json.weeklyTokenLimit.percentRemaining; + var manaUsed = Math.max(0, Math.round(100 - pct)); + lines.push(ctx.line.progress({ + label: "Mana Bar", + used: manaUsed, + limit: 100, + format: { kind: "percent" }, + })); + } + + // Rate Limited badge — only when actively limited + if ( + json.rollingFiveHourLimit && + json.rollingFiveHourLimit.limited === true + ) { + lines.push( + ctx.line.badge({ + label: "Rate Limited", + text: "Rate limited", + color: "#ef4444", + }) + ); + } + + // Subscription — legacy request count, only shown if NOT on v3 rate limits + var onV3 = !!json.rollingFiveHourLimit || !!json.weeklyTokenLimit; + if (!onV3 && json.subscription && typeof json.subscription.limit === "number") { + var sub = json.subscription; + var subOpts = { + label: "Subscription", + used: sub.requests, + limit: sub.limit, + format: { kind: "count", suffix: "requests" }, + }; + var subReset = ctx.util.toIso(sub.renewsAt); + if (subReset) subOpts.resetsAt = subReset; + lines.push(ctx.line.progress(subOpts)); + } + + // Free Tool Calls — legacy only, zeroed out on v3 + if (!onV3 && json.freeToolCalls && typeof json.freeToolCalls.limit === "number" && json.freeToolCalls.limit > 0) { + var ftc = json.freeToolCalls; + var ftcOpts = { + label: "Free Tool Calls", + used: Math.round(ftc.requests), + limit: ftc.limit, + format: { kind: "count", suffix: "requests" }, + }; + var ftcReset = ctx.util.toIso(ftc.renewsAt); + if (ftcReset) ftcOpts.resetsAt = ftcReset; + lines.push(ctx.line.progress(ftcOpts)); + } + + // Search — hourly search quota (detail) + if ( + json.search && + json.search.hourly && + typeof json.search.hourly.limit === "number" + ) { + var srch = json.search.hourly; + var srchOpts = { + label: "Search", + used: srch.requests, + limit: srch.limit, + format: { kind: "count", suffix: "requests" }, + periodDurationMs: ONE_HOUR_MS, + }; + var srchReset = ctx.util.toIso(srch.renewsAt); + if (srchReset) srchOpts.resetsAt = srchReset; + lines.push(ctx.line.progress(srchOpts)); + } + + return { lines: lines }; + } + + globalThis.__openusage_plugin = { id: "synthetic", probe: probe }; +})(); diff --git a/plugins/synthetic/plugin.json b/plugins/synthetic/plugin.json new file mode 100644 index 00000000..c05c023a --- /dev/null +++ b/plugins/synthetic/plugin.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "synthetic", + "name": "Synthetic", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "lines": [ + { "type": "progress", "label": "5h Rate Limit", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Mana Bar", "scope": "overview" }, + { "type": "badge", "label": "Rate Limited", "scope": "detail" }, + { "type": "progress", "label": "Subscription", "scope": "overview", "primaryOrder": 2 }, + { "type": "progress", "label": "Free Tool Calls", "scope": "detail" }, + { "type": "progress", "label": "Search", "scope": "detail" } + ], + "links": [ + { "label": "Dashboard", "url": "https://synthetic.new/billing" } + ] +} diff --git a/plugins/synthetic/plugin.test.js b/plugins/synthetic/plugin.test.js new file mode 100644 index 00000000..05885fdc --- /dev/null +++ b/plugins/synthetic/plugin.test.js @@ -0,0 +1,752 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeCtx } from "../test-helpers.js"; + +const PI_AUTH = "~/.pi/agent/auth.json"; +const PI_MODELS = "~/.pi/agent/models.json"; +const FACTORY_SETTINGS = "~/.factory/settings.json"; +const OC_AUTH = "~/.local/share/opencode/auth.json"; +const API_URL = "https://api.synthetic.new/v2/quotas"; + +const loadPlugin = async () => { + await import("./plugin.js"); + return globalThis.__openusage_plugin; +}; + +function setPiAuth(ctx, key, providerName) { + var obj = {}; + obj[providerName || "synthetic"] = { type: "api_key", key: key }; + ctx.host.fs.writeText(PI_AUTH, JSON.stringify(obj)); +} + +function setPiModels(ctx, key, providerName) { + var providers = {}; + providers[providerName || "synthetic"] = { apiKey: key }; + ctx.host.fs.writeText(PI_MODELS, JSON.stringify({ providers: providers })); +} + +function setFactorySettings(ctx, apiKey, baseUrl) { + ctx.host.fs.writeText( + FACTORY_SETTINGS, + JSON.stringify({ + customModels: [ + { + model: "some-model", + baseUrl: baseUrl || "https://api.synthetic.new/openai/v1", + apiKey: apiKey, + displayName: "Test [Synthetic]", + }, + ], + }) + ); +} + +function setOpenCodeAuth(ctx, key, providerName) { + var obj = {}; + obj[providerName || "synthetic"] = { key: key }; + ctx.host.fs.writeText(OC_AUTH, JSON.stringify(obj)); +} + +function setEnvKey(ctx, key) { + ctx.host.env.get.mockImplementation((name) => + name === "SYNTHETIC_API_KEY" ? key : null + ); +} + +function setEnv(ctx, envValues) { + ctx.host.env.get.mockImplementation((name) => + Object.prototype.hasOwnProperty.call(envValues, name) ? envValues[name] : null + ); +} + +function successPayload(overrides) { + var base = { + subscription: { + limit: 600, + requests: 120, + renewsAt: "2026-04-30T20:18:54.144Z", + }, + search: { + hourly: { + limit: 250, + requests: 15, + renewsAt: "2026-03-30T16:18:54.145Z", + }, + }, + freeToolCalls: { + limit: 0, + requests: 0, + renewsAt: "2026-03-31T15:18:54.317Z", + }, + weeklyTokenLimit: { + nextRegenAt: "2026-03-30T16:20:39.000Z", + percentRemaining: 75, + }, + rollingFiveHourLimit: { + nextTickAt: "2026-03-30T15:30:29.000Z", + tickPercent: 0.05, + remaining: 450, + max: 600, + limited: false, + }, + }; + return Object.assign({}, base, overrides); +} + +function mockHttp(ctx, payload, status) { + ctx.host.http.request.mockReturnValue({ + status: status || 200, + headers: {}, + bodyText: JSON.stringify(payload !== undefined ? payload : successPayload()), + }); +} + +describe("synthetic plugin", () => { + let plugin; + + beforeEach(async () => { + delete globalThis.__openusage_plugin; + vi.resetModules(); + plugin = await loadPlugin(); + }); + + describe("registration", () => { + it("registers with correct id", () => { + expect(plugin.id).toBe("synthetic"); + expect(typeof plugin.probe).toBe("function"); + }); + }); + + describe("authentication", () => { + it("throws when no sources have a key", () => { + expect(() => plugin.probe(makeCtx())).toThrow( + "Synthetic API key not found" + ); + }); + + // --- Pi auth.json (source 1) --- + + it("reads key from Pi auth.json", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_piauth"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_piauth"); + }); + + it("finds key under alternate provider names in Pi auth.json", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_altname", "synthetic.new"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_altname"); + }); + + it("skips Pi auth.json when key is empty", () => { + var ctx = makeCtx(); + setPiAuth(ctx, " "); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_envkey"); + }); + + it("skips Pi auth.json when invalid JSON", () => { + var ctx = makeCtx(); + ctx.host.fs.writeText(PI_AUTH, "not json {{{"); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_envkey"); + }); + + it("skips Pi auth.json when no matching provider name", () => { + var ctx = makeCtx(); + ctx.host.fs.writeText(PI_AUTH, JSON.stringify({ other: { key: "sk_other" } })); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_envkey"); + }); + + // --- Pi models.json (source 2) --- + + it("reads key from Pi models.json providers", () => { + var ctx = makeCtx(); + setPiModels(ctx, "syn_models"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_models"); + }); + + it("finds key under alternate provider names in Pi models.json", () => { + var ctx = makeCtx(); + setPiModels(ctx, "syn_altmodel", "syn"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_altmodel"); + }); + + it("Pi auth.json takes precedence over Pi models.json", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_fromauth"); + setPiModels(ctx, "syn_frommodels"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_fromauth"); + }); + + // --- PI_CODING_AGENT_DIR override --- + + it("respects PI_CODING_AGENT_DIR for auth.json", () => { + var ctx = makeCtx(); + setEnv(ctx, { PI_CODING_AGENT_DIR: "~/custom/pi" }); + ctx.host.fs.writeText( + "~/custom/pi/auth.json", + JSON.stringify({ synthetic: { type: "api_key", key: "syn_custom" } }) + ); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_custom"); + }); + + it("respects PI_CODING_AGENT_DIR for models.json", () => { + var ctx = makeCtx(); + setEnv(ctx, { PI_CODING_AGENT_DIR: "~/custom/pi" }); + ctx.host.fs.writeText( + "~/custom/pi/models.json", + JSON.stringify({ providers: { synthetic: { apiKey: "syn_custommodels" } } }) + ); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_custommodels"); + }); + + // --- Factory/Droid settings.json (source 3) --- + + it("reads key from Factory customModels with synthetic.new baseUrl", () => { + var ctx = makeCtx(); + setFactorySettings(ctx, "syn_factory"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_factory"); + }); + + it("matches any baseUrl containing synthetic.new", () => { + var ctx = makeCtx(); + setFactorySettings(ctx, "syn_custom", "https://custom.synthetic.new/v1"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_custom"); + }); + + it("skips Factory models without synthetic.new baseUrl", () => { + var ctx = makeCtx(); + ctx.host.fs.writeText( + FACTORY_SETTINGS, + JSON.stringify({ + customModels: [ + { baseUrl: "https://api.openai.com/v1", apiKey: "sk_other" }, + ], + }) + ); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_envkey"); + }); + + it("Pi sources take precedence over Factory", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_pi"); + setFactorySettings(ctx, "syn_factory"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_pi"); + }); + + // --- OpenCode auth.json (source 4) --- + + it("reads key from OpenCode auth.json", () => { + var ctx = makeCtx(); + setOpenCodeAuth(ctx, "syn_opencode"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_opencode"); + }); + + it("Pi sources take precedence over OpenCode", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_pi"); + setOpenCodeAuth(ctx, "syn_oc"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_pi"); + }); + + // --- Env var (source 5) --- + + it("falls back to SYNTHETIC_API_KEY env var", () => { + var ctx = makeCtx(); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_envkey"); + }); + + it("file sources take precedence over env var", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_filekey"); + setEnvKey(ctx, "syn_envkey"); + mockHttp(ctx); + plugin.probe(ctx); + var call = ctx.host.http.request.mock.calls[0][0]; + expect(call.headers.Authorization).toBe("Bearer syn_filekey"); + }); + }); + + describe("HTTP request", () => { + it("sends correct request", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + plugin.probe(ctx); + var 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 syn_testkey"); + expect(call.headers.Accept).toBe("application/json"); + expect(call.timeoutMs).toBe(15000); + }); + }); + + describe("error handling", () => { + it("throws on network error", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + ctx.host.http.request.mockImplementation(() => { + throw new Error("ECONNREFUSED"); + }); + expect(() => plugin.probe(ctx)).toThrow("Request failed. Check your connection."); + }); + + it("throws on HTTP 401", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx, {}, 401); + expect(() => plugin.probe(ctx)).toThrow("API key invalid or expired"); + }); + + it("throws on HTTP 403", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx, {}, 403); + expect(() => plugin.probe(ctx)).toThrow("API key invalid or expired"); + }); + + it("throws on HTTP 500", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx, {}, 500); + expect(() => plugin.probe(ctx)).toThrow("HTTP 500"); + }); + + it("throws on unparseable JSON", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: "not json", + }); + expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data"); + }); + }); + + describe("5h Rate Limit line", () => { + it("shows used and limit from API max", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "5h Rate Limit"); + expect(line).toBeTruthy(); + expect(line.type).toBe("progress"); + expect(line.used).toBe(150); // 600 - 450 + expect(line.limit).toBe(600); + expect(line.format.kind).toBe("count"); + expect(line.format.suffix).toBe("requests"); + }); + + it("uses dynamic max, not hardcoded 600", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + rollingFiveHourLimit: { + nextTickAt: "2026-03-30T15:30:29.000Z", + tickPercent: 0.05, + remaining: 300, + max: 400, + limited: false, + }, + }) + ); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "5h Rate Limit"); + expect(line.used).toBe(100); // 400 - 300 + expect(line.limit).toBe(400); + }); + + it("does not include resetsAt (nextTickAt is a partial tick, not a full reset)", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "5h Rate Limit"); + expect(line.resetsAt).toBeUndefined(); + expect(line.periodDurationMs).toBeUndefined(); + }); + + it("shows full usage when remaining is 0", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + rollingFiveHourLimit: { + nextTickAt: "2026-03-30T15:30:29.000Z", + tickPercent: 0.05, + remaining: 0, + max: 600, + limited: true, + }, + }) + ); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "5h Rate Limit"); + expect(line.used).toBe(600); + expect(line.limit).toBe(600); + }); + + it("absent when rollingFiveHourLimit missing", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "5h Rate Limit"); + expect(line).toBeUndefined(); + }); + }); + + describe("Mana Bar line", () => { + it("shows percent used", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Mana Bar"); + expect(line).toBeTruthy(); + expect(line.type).toBe("progress"); + expect(line.used).toBe(25); // 100 - 75 + expect(line.limit).toBe(100); + expect(line.format.kind).toBe("percent"); + }); + + it("used is 0 when percentRemaining is 100", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + weeklyTokenLimit: { + nextRegenAt: "2026-03-30T16:20:39.000Z", + percentRemaining: 100, + }, + }) + ); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Mana Bar"); + expect(line.used).toBe(0); + }); + + it("used is 100 when percentRemaining is 0", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + weeklyTokenLimit: { + nextRegenAt: "2026-03-30T16:20:39.000Z", + percentRemaining: 0, + }, + }) + ); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Mana Bar"); + expect(line.used).toBe(100); + }); + + it("does not include resetsAt (nextRegenAt is a partial regen tick, not a full reset)", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Mana Bar"); + expect(line.resetsAt).toBeUndefined(); + expect(line.periodDurationMs).toBeUndefined(); + }); + + it("absent when weeklyTokenLimit missing", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.weeklyTokenLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Mana Bar"); + expect(line).toBeUndefined(); + }); + }); + + describe("Rate Limited badge", () => { + it("absent when limited is false", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var badge = result.lines.find((l) => l.label === "Rate Limited"); + expect(badge).toBeUndefined(); + }); + + it("present when limited is true", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + rollingFiveHourLimit: { + nextTickAt: "2026-03-30T15:30:29.000Z", + tickPercent: 0.05, + remaining: 0, + max: 600, + limited: true, + }, + }) + ); + var result = plugin.probe(ctx); + var badge = result.lines.find((l) => l.label === "Rate Limited"); + expect(badge).toBeTruthy(); + expect(badge.type).toBe("badge"); + expect(badge.color).toBe("#ef4444"); + }); + + it("absent when rollingFiveHourLimit missing", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var badge = result.lines.find((l) => l.label === "Rate Limited"); + expect(badge).toBeUndefined(); + }); + }); + + describe("Subscription line", () => { + it("hidden when user is on v3 rate limits", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); // default payload has both v3 fields and subscription + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Subscription"); + expect(line).toBeUndefined(); + }); + + it("shown for legacy users without v3 fields", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Subscription"); + expect(line).toBeTruthy(); + expect(line.type).toBe("progress"); + expect(line.used).toBe(120); + expect(line.limit).toBe(600); + expect(line.format.kind).toBe("count"); + expect(line.format.suffix).toBe("requests"); + }); + + it("includes resetsAt for legacy users", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Subscription"); + expect(line.resetsAt).toBe("2026-04-30T20:18:54.144Z"); + }); + + it("absent when subscription missing", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.subscription; + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Subscription"); + expect(line).toBeUndefined(); + }); + }); + + describe("Free Tool Calls line", () => { + it("hidden when user is on v3 rate limits", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); // default payload is v3 with freeToolCalls limit: 0 + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Free Tool Calls"); + expect(line).toBeUndefined(); + }); + + it("shown for legacy users with non-zero limit", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + payload.freeToolCalls = { limit: 500, requests: 53.5, renewsAt: "2026-03-18T18:12:22.366Z" }; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Free Tool Calls"); + expect(line).toBeTruthy(); + expect(line.used).toBe(54); // rounded from 53.5 + expect(line.limit).toBe(500); + expect(line.format.kind).toBe("count"); + expect(line.format.suffix).toBe("requests"); + }); + + it("includes resetsAt for legacy users", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + payload.freeToolCalls = { limit: 500, requests: 0, renewsAt: "2026-03-18T18:12:22.366Z" }; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Free Tool Calls"); + expect(line.resetsAt).toBe("2026-03-18T18:12:22.366Z"); + }); + + it("hidden for legacy users when limit is 0", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.rollingFiveHourLimit; + delete payload.weeklyTokenLimit; + payload.freeToolCalls = { limit: 0, requests: 0, renewsAt: "2026-03-31T15:18:54.317Z" }; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Free Tool Calls"); + expect(line).toBeUndefined(); + }); + }); + + describe("Search line", () => { + it("shows hourly search count", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Search"); + expect(line).toBeTruthy(); + expect(line.type).toBe("progress"); + expect(line.used).toBe(15); + expect(line.limit).toBe(250); + expect(line.format.kind).toBe("count"); + expect(line.format.suffix).toBe("requests"); + expect(line.periodDurationMs).toBe(60 * 60 * 1000); + }); + + it("includes resetsAt from renewsAt", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Search"); + expect(line.resetsAt).toBe("2026-03-30T16:18:54.145Z"); + }); + + it("absent when search missing", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload(); + delete payload.search; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Search"); + expect(line).toBeUndefined(); + }); + }); + + describe("full success", () => { + it("returns all lines in correct order", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx); + var result = plugin.probe(ctx); + expect(result.lines.length).toBe(3); // v3 user: no badge, no subscription + expect(result.lines[0].label).toBe("5h Rate Limit"); + expect(result.lines[1].label).toBe("Mana Bar"); + expect(result.lines[2].label).toBe("Search"); + }); + + it("returns 5 lines when rate limited", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp( + ctx, + successPayload({ + rollingFiveHourLimit: { + nextTickAt: "2026-03-30T15:30:29.000Z", + tickPercent: 0.05, + remaining: 0, + max: 600, + limited: true, + }, + }) + ); + var result = plugin.probe(ctx); + expect(result.lines.length).toBe(4); // v3 user rate limited: no subscription + expect(result.lines[2].label).toBe("Rate Limited"); + expect(result.lines[3].label).toBe("Search"); + }); + }); +}); diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index b475f3a6..2b090cb9 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -const WHITELISTED_ENV_VARS: [&str; 14] = [ +const WHITELISTED_ENV_VARS: [&str; 16] = [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", @@ -26,6 +26,8 @@ const WHITELISTED_ENV_VARS: [&str; 14] = [ "MINIMAX_API_KEY", "MINIMAX_API_TOKEN", "MINIMAX_CN_API_KEY", + "SYNTHETIC_API_KEY", + "PI_CODING_AGENT_DIR", ]; fn last_non_empty_trimmed_line(text: &str) -> Option { From 2a8f5505e66e305cdc0ba7610e2382f02aaad19e Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 1 Apr 2026 15:54:55 -0600 Subject: [PATCH 2/2] fix: address synthetic plugin review feedback --- docs/providers/synthetic.md | 20 +++++++------ plugins/synthetic/plugin.js | 50 +++++++++++++++++++++++++------- plugins/synthetic/plugin.test.js | 39 ++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md index 64fba4e6..67053197 100644 --- a/docs/providers/synthetic.md +++ b/docs/providers/synthetic.md @@ -139,19 +139,21 @@ No plan name is returned by the API. The plugin does not set a plan label. ## Displayed Lines -| Line | Scope | Condition | Description | -|----------------|----------|----------------------------------|------------------------------------------------| -| 5h Rate Limit | overview | `rollingFiveHourLimit` present | Requests remaining in 5-hour rolling window | -| Mana Bar | overview | `weeklyTokenLimit` present | Weekly token budget as percentage | -| Rate Limited | detail | `rollingFiveHourLimit.limited` | Red badge shown only when actively rate limited| -| Subscription | detail | `subscription` present | Legacy request count for billing period | -| Search | detail | `search.hourly` present | Hourly search request quota | +| Line | Scope | Condition | Description | +|-----------------|----------|-----------------------------------------------------------|-----------------------------------------------| +| 5h Rate Limit | overview | `rollingFiveHourLimit.max` and `remaining` are numeric | Usage in 5-hour rolling window (used / limit) | +| Mana Bar | overview | `weeklyTokenLimit.percentRemaining` is numeric | Weekly token budget as percentage used | +| Rate Limited | detail | `rollingFiveHourLimit.limited` | Red badge shown only when actively rate limited | +| Subscription | overview | `subscription` present and no usable v3 quota lines exist | Legacy request count for billing period | +| Free Tool Calls | detail | `freeToolCalls.limit > 0` and no usable v3 quota lines exist | Legacy free tool-call quota | +| Search | detail | `search.hourly` present | Hourly search request quota | 5h Rate Limit is the primary (tray icon) metric — it's the first constraint users hit during active use. Progress lines include: -- `resetsAt` — ISO timestamp of next restoration tick (5h, mana) or renewal (subscription, search) -- `periodDurationMs` — 5 hours (rate limit), 1 week (mana), or 1 hour (search) +- 5h Rate Limit / Mana Bar: usage fields only, with no reset metadata +- Subscription / Free Tool Calls: `resetsAt` from `renewsAt` +- Search: `resetsAt` from `renewsAt` and `periodDurationMs = 3600000` ## Errors diff --git a/plugins/synthetic/plugin.js b/plugins/synthetic/plugin.js index 91970482..27d7ae2c 100644 --- a/plugins/synthetic/plugin.js +++ b/plugins/synthetic/plugin.js @@ -48,6 +48,39 @@ } } + function hasUsableRollingFiveHourLimit(value) { + return ( + value && + typeof value.max === "number" && + typeof value.remaining === "number" + ); + } + + function hasUsableWeeklyTokenLimit(value) { + return value && typeof value.percentRemaining === "number"; + } + + function normalizeApiErrorMessage(json, status) { + var fallback = "Request failed (HTTP " + status + ")"; + if (!json || typeof json !== "object") return fallback; + + var direct = typeof json.error === "string" ? json.error.trim() : ""; + if (direct) return direct; + + if (json.error && typeof json.error === "object") { + var nested = typeof json.error.message === "string" ? json.error.message.trim() : ""; + if (nested) return nested; + + var serialized = JSON.stringify(json.error); + if (serialized && serialized !== "{}") return serialized; + } + + var message = typeof json.message === "string" ? json.message.trim() : ""; + if (message) return message; + + return fallback; + } + function loadApiKey(ctx) { var piDir = resolvePiAgentDir(ctx); @@ -121,8 +154,7 @@ } if (resp.status < 200 || resp.status >= 300) { - var msg = - json && json.error ? json.error : "Request failed (HTTP " + resp.status + ")"; + var msg = normalizeApiErrorMessage(json, resp.status); throw msg; } @@ -133,10 +165,7 @@ var lines = []; // 5h Rate Limit — hero metric (immediate blocker) - if ( - json.rollingFiveHourLimit && - typeof json.rollingFiveHourLimit.max === "number" - ) { + if (hasUsableRollingFiveHourLimit(json.rollingFiveHourLimit)) { var rfl = json.rollingFiveHourLimit; var rflUsed = Math.max(0, rfl.max - rfl.remaining); lines.push(ctx.line.progress({ @@ -148,10 +177,7 @@ } // Mana Bar — longer-term weekly budget - if ( - json.weeklyTokenLimit && - typeof json.weeklyTokenLimit.percentRemaining === "number" - ) { + if (hasUsableWeeklyTokenLimit(json.weeklyTokenLimit)) { var pct = json.weeklyTokenLimit.percentRemaining; var manaUsed = Math.max(0, Math.round(100 - pct)); lines.push(ctx.line.progress({ @@ -177,7 +203,9 @@ } // Subscription — legacy request count, only shown if NOT on v3 rate limits - var onV3 = !!json.rollingFiveHourLimit || !!json.weeklyTokenLimit; + var onV3 = + hasUsableRollingFiveHourLimit(json.rollingFiveHourLimit) || + hasUsableWeeklyTokenLimit(json.weeklyTokenLimit); if (!onV3 && json.subscription && typeof json.subscription.limit === "number") { var sub = json.subscription; var subOpts = { diff --git a/plugins/synthetic/plugin.test.js b/plugins/synthetic/plugin.test.js index 05885fdc..5df90d77 100644 --- a/plugins/synthetic/plugin.test.js +++ b/plugins/synthetic/plugin.test.js @@ -367,6 +367,13 @@ describe("synthetic plugin", () => { expect(() => plugin.probe(ctx)).toThrow("HTTP 500"); }); + it("uses nested API error messages when the error payload is structured", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + mockHttp(ctx, { error: { message: "Credits required for this feature." } }, 429); + expect(() => plugin.probe(ctx)).toThrow("Credits required for this feature."); + }); + it("throws on unparseable JSON", () => { var ctx = makeCtx(); setPiAuth(ctx, "syn_testkey"); @@ -599,6 +606,36 @@ describe("synthetic plugin", () => { expect(line.format.suffix).toBe("requests"); }); + it("shown when v3 blocks are placeholders without usable numeric fields", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload({ + rollingFiveHourLimit: {}, + weeklyTokenLimit: {}, + }); + payload.freeToolCalls = { + limit: 500, + requests: 53.5, + renewsAt: "2026-03-18T18:12:22.366Z", + }; + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + expect(result.lines.find((l) => l.label === "Subscription")).toBeTruthy(); + expect(result.lines.find((l) => l.label === "Free Tool Calls")).toBeTruthy(); + }); + + it("hidden when either v3 quota block is usable", () => { + var ctx = makeCtx(); + setPiAuth(ctx, "syn_testkey"); + var payload = successPayload({ + rollingFiveHourLimit: {}, + }); + mockHttp(ctx, payload); + var result = plugin.probe(ctx); + var line = result.lines.find((l) => l.label === "Subscription"); + expect(line).toBeUndefined(); + }); + it("includes resetsAt for legacy users", () => { var ctx = makeCtx(); setPiAuth(ctx, "syn_testkey"); @@ -728,7 +765,7 @@ describe("synthetic plugin", () => { expect(result.lines[2].label).toBe("Search"); }); - it("returns 5 lines when rate limited", () => { + it("returns 4 lines when rate limited", () => { var ctx = makeCtx(); setPiAuth(ctx, "syn_testkey"); mockHttp(