hyper: persist rotated refresh tokens and read credentials in either auth.json shape
19 unmodified lines202122232424252627282930262728313233343536303132333437383940414243444546109 unmodified linesExpand all19 unmodified lines```
## Auth
Resolution order:`/login hyper` offers the subscription device-code flow first (`POST /device/auth`, then`GET /device/auth/<code>` until approved, then `POST /token/exchange`); the link andverification code are printed in the login dialog and the token is refreshedautomatically. `HYPER_API_KEY` covers throwaway shells and the extension's own configresolves to it (`!printf %s "$HYPER_API_KEY"`), so when neither exists the provider reportsan unresolved key rather than sending a placeholder as the credential. There is no key file.
1. `/login hyper` - device-code login against `POST /device/auth` for a Hyper subscription, or a pasted API key2. `HYPER_API_KEY`Credentials live in Prime Agent's `auth.json` and are tried in list order, so the storedorder is the precedence: subscription first, API key as the fallback that gets used whenthe subscription is missing, stale, or blocked. Verified both ways: a turn still completeswith the API key replaced by a bogus value (subscription used), and with the subscriptionreplaced by an expired one (API key used).
There is no key file. `/login` stores the credential in Prime Agent's `auth.json`, whichPrime checks before the extension's own config, so that is the durable source.`HYPER_API_KEY` covers throwaway shells, and when neither is present the provider reportsan unresolved key (`!printf %s "$HYPER_API_KEY"` resolves to nothing) rather than sending aplaceholder as the credential.The access token lasts an hour. Every `POST /token/exchange` returns a **rotated** refreshtoken and invalidates the previous one, so the extension returns the exchanged credentialand Prime Agent persists it; a rejected refresh is reported as "run /login hyper again"rather than as a generic HTTP error. Removing a single account happens in the `/login`provider list; `prime-agent logout hyper` drops every credential for the provider,including the API key.
## Why Chat Completions
Hyper exposes three chat endpoints. This extension uses `POST /v1/chat/completions` because109 unmodified linesExpand all21 unmodified lines22232425262627282930313233343536303137383940414219 unmodified lines21 unmodified lines if (!body.trim()) throw new Error(`${url} returned an empty body`); return JSON.parse(body) as unknown;}
/** `{error: {message}}` and `{error: {error: {message}}}` both appear in Hyper's docs. *//** * `{error: {message}}` (the OpenAI-shaped endpoints), `{error: {error: {message}}}` * (Anthropic-shaped), and a bare `{error: "invalid refresh_token"}` on `/token/exchange` * all appear in practice, and the last one is what tells a rejected session apart from a * rejected request. */function errorDetail(body: string): string { try { const payload = JSON.parse(body) as { error?: unknown }; const error = payload.error; if (typeof error === "string") return error; const error = (payload.error ?? {}) as { message?: unknown; error?: { message?: unknown } }; const message = error.message ?? error.error?.message; const fields = (error ?? {}) as { message?: unknown; error?: { message?: unknown } }; const message = fields.message ?? fields.error?.message; return typeof message === "string" ? message : ""; } catch { return body.slice(0, 120).trim(); }19 unmodified lines39 unmodified lines4041424344454644454647484950515253545556575859606162636465505152666768697014 unmodified lines39 unmodified lines return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";};
/** * Key saved by `/login hyper` - a pasted key, or the device-code subscription login. * This and `HYPER_API_KEY` are the whole story: no key file, so the credential has one * home, and a missing key is reported as unresolved rather than sent as a placeholder. * The API-key credential in an auth.json payload. A provider entry is one credential or a * list of them (Prime Agent pools accounts: a subscription and an API key coexist), so both * shapes resolve here. */export function storedKeyFrom(payload: unknown): string | undefined { if (typeof payload !== "object" || payload === null) return undefined; const entry = Object.getOwnPropertyDescriptor(payload, PROVIDER)?.value; for (const credential of Array.isArray(entry) ? entry : [entry]) { if (typeof credential !== "object" || credential === null) continue; const key = Object.getOwnPropertyDescriptor(credential, "key")?.value; if (typeof key === "string" && key !== "") return key; } return undefined;}
/** * Key saved by `/login hyper` - a pasted key, or the device-code subscription login. This * and `HYPER_API_KEY` are the whole story: no key file, so the credential has one home, and * a missing key is reported as unresolved rather than sent as a placeholder. */export function storedKey(): string | undefined { try { const payload = JSON.parse(readFileSync(AUTH_FILE(), "utf-8")) as Record<string, { key?: unknown }>; const key = payload[PROVIDER]?.key; return typeof key === "string" && key !== "" ? key : undefined; return storedKeyFrom(JSON.parse(readFileSync(AUTH_FILE(), "utf-8"))); } catch { return undefined; }}14 unmodified lines97 unmodified lines989910010110210310210310410510610710810939 unmodified lines97 unmodified lines throw new Error("Hyper device authorization expired; run /login hyper again");}
/** * The subscription's refresh token never rotates: every exchange spends the same * refresh token and returns a fresh access token, so the credential keeps it. * Every exchange returns a **new** refresh token alongside the access token, and the old * one stops working, so the rotated value has to be persisted. Prime Agent stores whatever * `refreshToken()` returns, which is why this returns the exchanged refresh token rather * than the one it was called with. */async function exchange(refreshToken: string): Promise<OAuthCredentials> { const payload = await postJson(TOKEN_EXCHANGE_URL, { refresh_token: refreshToken }, undefined, POLL_TIMEOUT_MS); const now = Date.now();39 unmodified lines12 unmodified lines131415161717181920212223242526272829303132333435363774 unmodified lines12 unmodified linesimport { fileURLToPath } from "node:url";import type { OpenAICompletionsCompat } from "@earendil-works/pi-ai";import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";import { type ModelFacts, toProviderModel } from "./catalog.js";import { API_KEY_CONFIG, ENV_VAR, resolveKey, storedKey } from "./hyper.js";import { API_KEY_CONFIG, ENV_VAR, resolveKey, storedKey, storedKeyFrom } from "./hyper.js";import hyper from "./index.js";
const here = dirname(fileURLToPath(import.meta.url));const catalog = JSON.parse(readFileSync(join(here, "catalog.json"), "utf-8")) as { models: ModelFacts[] };
// A provider entry is one credential or a list of them; both shapes must resolve.assert.equal(storedKeyFrom({ hyper: { type: "api_key", key: "k1" } }), "k1");assert.equal( storedKeyFrom({ hyper: [{ type: "oauth", access: "a" }, { type: "api_key", key: "k2" }] }), "k2", "a subscription entry must not hide the API key beside it",);assert.equal(storedKeyFrom({ hyper: [{ type: "oauth", access: "a" }] }), undefined);assert.equal(storedKeyFrom({}), undefined);assert.equal(storedKeyFrom(null), undefined);
process.env.PI_OFFLINE = "1";assert.equal( API_KEY_CONFIG, `!printf %s "$${ENV_VAR}"`,74 unmodified lines