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.
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
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")
|
|
})
|
|
})
|