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:
108
test/balance.test.ts
Normal file
108
test/balance.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user