/**
* Charm Hyper (https://hyper.charm.land) provider for Prime Agent.
*
* Hyper is a router over many coding models with one key or one subscription login.
* All models are exposed through the OpenAI Chat Completions endpoint
* (`POST /v1/chat/completions`), which is the only one of Hyper's three chat
* endpoints that reports prompt-cache usage and accepts an explicit "stop thinking".
*
* Auth: `/login hyper` (device-code subscription login or a pasted key, stored in Prime
* Agent's auth.json), or HYPER_API_KEY for throwaway shells. There is no key file.
*
* Models come from `GET /v1/models` and models.dev via the committed `catalog.json` and the last
* successful refresh in ~/.prime/agent/hyper-catalog.json. The catalog that is newest is
* registered immediately, so startup never waits on the network, and a background refresh
* re-registers when Hyper's list changed.
*/
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
type Catalog,
type HyperProviderModel,
type ModelFacts,
fetchFacts,
readCatalog,
sameModels,
toCatalog,
toProviderModels,
writeCatalog,
} from "./catalog.js";
import { API_BASE_URL, API_KEY_CONFIG, DISPLAY_NAME, PROVIDER, cachePath, fetchBalance, isOffline } from "./hyper.js";
import { loginHyper, refreshHyperToken } from "./oauth.js";
/** The model list committed to this repository: first-run seed and the probed thinking controls. */
const BAKED_CATALOG = join(dirname(fileURLToPath(import.meta.url)), "catalog.json");
const reported = new Set<string>();
const report = (message: string): void => {
if (reported.has(message)) return;
reported.add(message);
process.stderr.write(`hyper: ${message}\n`);
};
const newest = (baked: Catalog | undefined, cached: Catalog | undefined): Catalog | undefined =>
!baked ? cached : !cached ? baked : cached.generatedAt > baked.generatedAt ? cached : baked;
export default async function hyper(pi: ExtensionAPI): Promise<void> {
const baked = readCatalog(BAKED_CATALOG);
const cached = readCatalog();
// Waiting on the network here would put a request in every session worker's startup
// path, so it only happens when no catalog file survived at all.
const seed =
newest(baked, cached) ??
(isOffline()
? undefined
: await fetchFacts()
.then(toCatalog)
.catch((error: unknown) => {
report(`no models: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}));
let registered: HyperProviderModel[] = [];
const register = (models: readonly ModelFacts[]): HyperProviderModel[] => {
const configs = toProviderModels(models);
pi.registerProvider(PROVIDER, {
name: DISPLAY_NAME,
baseUrl: API_BASE_URL,
apiKey: API_KEY_CONFIG,
api: "openai-completions",
models: configs,
oauth: {
name: DISPLAY_NAME,
login: loginHyper,
refreshToken: refreshHyperToken,
getApiKey: (credentials) => credentials.access,
},
});
return configs;
};
registered = register(seed?.models ?? []);
const refresh = async (): Promise<void> => {
const fresh = await fetchFacts();
const configs = toProviderModels(fresh);
const changed = !sameModels(registered, configs);
if (changed || cached === undefined) writeCatalog(cachePath(), toCatalog(fresh));
if (changed) registered = register(fresh);
};
pi.registerCommand("hyper", {
description: "Show the Charm Hyper account balance and model count",
handler: async (_args, ctx) => {
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
if (!apiKey) {
ctx.ui.notify("No Charm Hyper key. Run /login hyper, or set HYPER_API_KEY.", "warning");
return;
}
try {
const balance = await fetchBalance(apiKey);
ctx.ui.notify(
`Charm Hyper: ${balance === undefined ? "balance unavailable" : `${balance} hypercredits`}, ${registered.length} models`,
"info",
);
} catch (error) {
ctx.ui.notify(`Charm Hyper: ${error instanceof Error ? error.message : String(error)}`, "warning");
}
},
});
// The refresh is what discovers models when no catalog file survived; never hold up
// extension loading for it, since it also runs in every session worker.
if (isOffline()) return;
void refresh().catch((error: unknown) => {
report(`keeping the previous model list: ${error instanceof Error ? error.message : String(error)}`);
});
}