diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md new file mode 100644 index 00000000..67053197 --- /dev/null +++ b/docs/providers/synthetic.md @@ -0,0 +1,167 @@ +# 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.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: +- 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 + +| 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..27d7ae2c --- /dev/null +++ b/plugins/synthetic/plugin.js @@ -0,0 +1,259 @@ +(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 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); + + // 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 = normalizeApiErrorMessage(json, resp.status); + throw msg; + } + + if (!json) { + throw "Could not parse usage data."; + } + + var lines = []; + + // 5h Rate Limit — hero metric (immediate blocker) + if (hasUsableRollingFiveHourLimit(json.rollingFiveHourLimit)) { + 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 (hasUsableWeeklyTokenLimit(json.weeklyTokenLimit)) { + 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 = + hasUsableRollingFiveHourLimit(json.rollingFiveHourLimit) || + hasUsableWeeklyTokenLimit(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..5df90d77 --- /dev/null +++ b/plugins/synthetic/plugin.test.js @@ -0,0 +1,789 @@ +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("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"); + 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("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"); + 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 4 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 {