new: [opencode-greenpt-usage] add GreenPT credit balance plugin

Show the remaining GreenPT API credit balance in the OpenCode TUI
sidebar. The key is resolved from the ``apiKey`` option, the
``GREENPT_API_KEY`` environment variable, or the active OpenCode
``auth.json`` (provider ``greenpt``); the section is hidden when no key
is present, so the plugin stays portable across machines and profiles.

The balance is read from the ``x-credits-remaining`` header returned by
a free ``HEAD /v1/chat/completions`` probe (HTTP 400 before any credit
is consumed), which avoids a billable call per refresh. Tests cover the
credential resolution, the header parsing and the live API.

Distribute via git clone; there is no npm publish.
This commit is contained in:
Stéphan Sainléger
2026-09-29 21:54:23 +02:00
commit 5c11754e2d
18 changed files with 5268 additions and 0 deletions

108
test/balance.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest"
import { fetchBalance, parseCreditHeader, type FetchLike } from "../src/balance"
const API_KEY = "test-key"
function headers(init: Record<string, string>): Headers {
return new Headers(init)
}
describe("parseCreditHeader", () => {
it("reads the live x-credits-remaining header", () => {
expect(parseCreditHeader(headers({ "x-credits-remaining": "5.309411174" }))).toEqual({
credits: 5.309411174,
raw: "5.309411174",
})
})
it("accepts the documented x-credit-balance fallback", () => {
expect(parseCreditHeader(headers({ "x-credit-balance": "12.5" }))).toEqual({
credits: 12.5,
raw: "12.5",
})
})
it("prefers the live header when both are present", () => {
const result = parseCreditHeader(
headers({ "x-credits-remaining": "1", "x-credit-balance": "2" }),
)
expect(result?.credits).toBe(1)
})
it("returns null when neither header is present", () => {
expect(parseCreditHeader(headers({ "content-type": "application/json" }))).toBeNull()
})
it("returns null for a malformed value", () => {
expect(parseCreditHeader(headers({ "x-credits-remaining": "not-a-number" }))).toBeNull()
})
it("returns null for undefined headers", () => {
expect(parseCreditHeader(undefined)).toBeNull()
})
})
describe("fetchBalance", () => {
it("returns the balance from a free 400 probe", async () => {
const fetchImpl: FetchLike = async () =>
new Response(null, {
status: 400,
headers: { "x-credits-remaining": "5.309411174" },
})
const result = await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
expect(result).toEqual({ credits: 5.309411174, raw: "5.309411174" })
})
it("uses HEAD first, without a body", async () => {
let method: string | undefined
const fetchImpl: FetchLike = async (_input, init) => {
method = init?.method
return new Response(null, { status: 400, headers: { "x-credits-remaining": "1" } })
}
await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
expect(method).toBe("HEAD")
})
it("normalises a trailing slash on the base URL", async () => {
let url: string | undefined
const fetchImpl: FetchLike = async (input) => {
url = input
return new Response(null, { status: 400, headers: { "x-credits-remaining": "1" } })
}
await fetchBalance("https://api.greenpt.ai/v1/", API_KEY, undefined, fetchImpl)
expect(url).toBe("https://api.greenpt.ai/v1/chat/completions")
})
it("reports unauthorized on 401", async () => {
const fetchImpl: FetchLike = async () => new Response(null, { status: 401 })
const result = await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
expect(result).toEqual({ kind: "unauthorized" })
})
it("falls back to GET when HEAD carries no header", async () => {
const methods: string[] = []
const fetchImpl: FetchLike = async (_input, init) => {
methods.push(init?.method ?? "GET")
if (init?.method === "HEAD") return new Response(null, { status: 400 })
return new Response(null, { status: 400, headers: { "x-credits-remaining": "7.5" } })
}
const result = await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
expect(methods).toEqual(["HEAD", "GET"])
expect(result).toEqual({ credits: 7.5, raw: "7.5" })
})
it("reports a missing header when none is present", async () => {
const fetchImpl: FetchLike = async () => new Response(null, { status: 400 })
const result = await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
expect(result).toEqual({ kind: "missing-header", status: 400 })
})
it("reports a network error", async () => {
const fetchImpl: FetchLike = async () => {
throw new Error("boom")
}
const result = await fetchBalance("https://api.greenpt.ai/v1", API_KEY, undefined, fetchImpl)
if (!("kind" in result)) throw new Error("expected an error result")
expect(result.kind).toBe("network")
})
})

93
test/credential.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it } from "vitest"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { credentialSearchDirs, resolveGreenptKey } from "../src/credential"
const created: string[] = []
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "greenpt-cred-"))
created.push(dir)
return dir
}
function writeAuth(dir: string, content: unknown): void {
writeFileSync(join(dir, "auth.json"), JSON.stringify(content))
}
afterEach(() => {
while (created.length > 0) {
const dir = created.pop()
if (dir) rmSync(dir, { recursive: true, force: true })
}
})
describe("resolveGreenptKey", () => {
it("prefers the explicit apiKey option", () => {
const dir = tempDir()
writeAuth(dir, { greenpt: { type: "api", key: "from-auth" } })
const result = resolveGreenptKey({ apiKey: "from-option" }, dir, { GREENPT_API_KEY: "from-env" })
expect(result).toEqual({ ok: true, key: "from-option" })
})
it("prefers the environment variable over the auth store", () => {
const dir = tempDir()
writeAuth(dir, { greenpt: { type: "api", key: "from-auth" } })
const result = resolveGreenptKey({}, dir, { GREENPT_API_KEY: "from-env" })
expect(result).toEqual({ ok: true, key: "from-env" })
})
it("reads the active auth store when nothing else is set", () => {
const dir = tempDir()
writeAuth(dir, { greenpt: { type: "api", key: "from-auth" } })
const result = resolveGreenptKey({}, dir, {})
expect(result).toEqual({ ok: true, key: "from-auth" })
})
it("ignores OAuth entries", () => {
const dir = tempDir()
writeAuth(dir, { greenpt: { type: "oauth", access: "token" } })
const result = resolveGreenptKey({}, dir, {})
expect(result).toEqual({ ok: false, reason: "missing" })
})
it("ignores empty keys", () => {
const dir = tempDir()
writeAuth(dir, { greenpt: { type: "api", key: "" } })
const result = resolveGreenptKey({}, dir, {})
expect(result).toEqual({ ok: false, reason: "missing" })
})
it("returns missing when no credential is available", () => {
const dir = tempDir()
const result = resolveGreenptKey({}, dir, {})
expect(result).toEqual({ ok: false, reason: "missing" })
})
it("falls back to XDG data home", () => {
const xdg = tempDir()
const stateDir = join(xdg, "opencode")
mkdirSync(stateDir)
writeAuth(stateDir, { greenpt: { type: "api", key: "from-xdg" } })
const result = resolveGreenptKey({}, undefined, { XDG_DATA_HOME: xdg })
expect(result).toEqual({ ok: true, key: "from-xdg" })
})
})
describe("credentialSearchDirs", () => {
it("puts the state dir first and de-duplicates", () => {
const dirs = credentialSearchDirs("/state", {
XDG_DATA_HOME: "/xdg",
OPENCODE_DATA_DIR: "/state",
})
expect(dirs[0]).toBe("/state")
expect(dirs).toContain(join("/xdg", "opencode"))
expect(new Set(dirs).size).toBe(dirs.length)
})
it("falls back to ~/.local/share/opencode", () => {
const dirs = credentialSearchDirs(undefined, { HOME: "/home/tester" })
expect(dirs).toContain(join("/home/tester", ".local", "share", "opencode"))
})
})

49
test/format.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest"
import { formatBalanceError, formatCredits, readFoldOpen } from "../src/format"
describe("formatCredits", () => {
it("formats with two decimals", () => {
expect(formatCredits(5.309411174)).toBe("5.31")
})
it("formats zero", () => {
expect(formatCredits(0)).toBe("0.00")
})
it("returns a dash for non-finite input", () => {
expect(formatCredits(Number.NaN)).toBe("-")
})
})
describe("formatBalanceError", () => {
it("renders each error kind", () => {
expect(formatBalanceError({ kind: "unauthorized" })).toContain("invalid")
expect(formatBalanceError({ kind: "network" })).toContain("network")
expect(formatBalanceError({ kind: "missing-header", status: 400 })).toContain("400")
expect(formatBalanceError({ kind: "no-credential", reason: "missing" })).toContain("GreenPT")
})
})
describe("readFoldOpen", () => {
it("defaults to open when the KV store is absent", () => {
expect(readFoldOpen(undefined)).toBe(true)
})
it("reads a persisted boolean", () => {
expect(readFoldOpen({ get: () => false })).toBe(false)
})
it("falls back when the stored value is not a boolean", () => {
expect(readFoldOpen({ get: () => "nope" })).toBe(true)
})
it("survives a throwing KV store", () => {
expect(
readFoldOpen({
get: () => {
throw new Error("kv down")
},
}),
).toBe(true)
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { fetchBalance } from "../src/balance"
import { DEFAULT_BASE_URL } from "../src/store"
const apiKey = process.env.GREENPT_API_KEY
const enabled = process.env.GREENPT_INTEGRATION === "1" && typeof apiKey === "string" && apiKey.length > 0
const suite = enabled ? describe : describe.skip
suite("GreenPT live API", () => {
it("returns a finite balance from the free HEAD probe", async () => {
const result = await fetchBalance(DEFAULT_BASE_URL, apiKey as string)
if ("kind" in result) {
throw new Error(`expected a balance, got error: ${result.kind}`)
}
expect(Number.isFinite(result.credits)).toBe(true)
expect(result.credits).toBeGreaterThanOrEqual(0)
})
it("reports unauthorized for an invalid key", async () => {
const result = await fetchBalance(DEFAULT_BASE_URL, "sk-definitely-invalid")
expect(result).toEqual({ kind: "unauthorized" })
})
})