hyper: take the key from auth.json or the env var only, drop ~/.charm.key
21 unmodified lines2223242526262728282930313230313233343536373888 unmodified lines1271281291301291301311311321331341351361371381391401411401421431441451461 unmodified line21 unmodified lines## Auth
Resolution order:
1. `/login hyper` - device-code login against `POST /device/auth`, for a Hyper subscription1. `/login hyper` - device-code login against `POST /device/auth` for a Hyper subscription, or a pasted API key2. `HYPER_API_KEY`3. `~/.charm.key`
The key from `/login` lives in Prime Agent's `auth.json`. `~/.charm.key` is read throughPrime Agent's own `!command` config resolver, so a missing key fails at request time withthe file path instead of sending the literal string `HYPER_API_KEY` as a key.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.
## Why Chat Completions
Hyper exposes three chat endpoints. This extension uses `POST /v1/chat/completions` because88 unmodified lines
## Relationship to the official extension
Charm publishes `@charmland/pi-hyper-provider` for upstream Pi. This one is independent andtargets this fork's extension API. Notable differences: this extension also reads`~/.charm.key`, refreshes the catalog in the background instead of during startup, takes modelmetadata from models.dev, and has no status-line integration.targets this fork's extension API. Notable differences: this extension refreshes the catalog inthe background instead of during startup, takes model metadata from models.dev, and has nostatus-line integration.
## Layout
| file | role || --- | --- || `index.ts` | extension entry: registration, background refresh, `/hyper` command || `catalog.ts` | `/v1/models` + models.dev parsing, catalog files, model-config derivation || `oauth.ts` | device-code login and refresh-token exchange || `hyper.ts` | provider identity, paths, key precedence, credit balance || `hyper.ts` | provider identity, paths, key resolution, credit balance || `http.ts` | JSON transport with timeouts and error extraction || `catalog.json` | committed model list || `regen.ts` | rebuilds `catalog.json` || `test.ts` | offline checks |1 unmodified line12345656789101112131415151617181920212223242526272 unmodified lines3031323334353637383940414243363738394445464741424348495051525354555657585960616263646 unmodified lines/** * Charm Hyper (https://hyper.charm.land) provider identity and auth for Prime Agent. */
import { existsSync } from "node:fs";import { getJson } from "./http.js";import { readFileSync } from "node:fs";import { homedir } from "node:os";import { join } from "node:path";import { getJson } from "./http.js";
export const PROVIDER = "hyper";export const DISPLAY_NAME = "Charm Hyper";export const SITE_URL = "https://hyper.charm.land";export const API_BASE_URL = `${SITE_URL}/v1`;export const ENV_VAR = "HYPER_API_KEY";export const KEY_FILE = join(homedir(), ".charm.key");
/** * What Prime Agent resolves when no credential is stored. `!`-prefixed values run through * the shell (bash -c), and an empty result counts as an unresolved key rather than an * empty string, which turns "no key" into a readable error instead of a 401. */export const API_KEY_CONFIG = `!printf %s "$${ENV_VAR}"`;
/** * Prime Agent's agent dir, mirrored here: importing the runtime for it drags a * jiti-hostile module graph into the extension load path. */export function agentDir(): string {2 unmodified lines if (configured === "~") return homedir(); return configured.startsWith("~/") ? join(homedir(), configured.slice(2)) : configured;}
const AUTH_FILE = (): string => join(agentDir(), "auth.json");
export const cachePath = (): string => join(agentDir(), "hyper-catalog.json");
export const isOffline = (): boolean => { const value = process.env.PI_OFFLINE; return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";};
/** * Key precedence: `/login hyper` (Prime checks stored credentials first), then this * config value, which Prime resolves as an env var name before treating it as a literal. * `~/.charm.key` is read by Prime's own `!command` resolver rather than here, so the * file stays the single source of truth and a missing key fails loudly at request time. * 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 apiKeyConfig(): string { if (process.env[ENV_VAR]) return ENV_VAR; return existsSync(KEY_FILE) ? `!cat ${KEY_FILE}` : ENV_VAR;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; } catch { return undefined; }}
/** Key for the probe scripts: `HYPER_API_KEY`, else the stored credential. */export const resolveKey = (): string | undefined => process.env[ENV_VAR] ?? storedKey();
/** The account's Hypercredit balance. `GET /v1/credits` is the only account endpoint. */export async function fetchBalance(apiKey: string): Promise<number | undefined> { const payload = await getJson(`${SITE_URL}/v1/credits`, { Authorization: `Bearer ${apiKey}` }); if (typeof payload !== "object" || payload === null) return undefined;6 unmodified lines4 unmodified lines567891011129101112131413 unmodified lines2829303134323334353631 unmodified lines6869707174727374757619 unmodified lines9697989910210010110210310417 unmodified lines4 unmodified lines * 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 precedence: * 1. `/login hyper` -> device-code subscription login * 2. HYPER_API_KEY * 3. ~/.charm.key * 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 refresh13 unmodified lines toCatalog, toProviderModels, writeCatalog,} from "./catalog.js";import { API_BASE_URL, DISPLAY_NAME, PROVIDER, apiKeyConfig, cachePath, fetchBalance, isOffline } from "./hyper.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");31 unmodified lines const configs = toProviderModels(models); pi.registerProvider(PROVIDER, { name: DISPLAY_NAME, baseUrl: API_BASE_URL, apiKey: apiKeyConfig(), apiKey: API_KEY_CONFIG, api: "openai-completions", models: configs, oauth: { name: DISPLAY_NAME,19 unmodified lines 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 or ~/.charm.key.", "warning"); ctx.ui.notify("No Charm Hyper key. Run /login hyper, or set HYPER_API_KEY.", "warning"); return; } try { const balance = await fetchBalance(apiKey);17 unmodified lines6 unmodified lines789101111121314151617171819202122232425262724293031252627282930313233343536373839404159 unmodified lines6 unmodified lines * covers the derivation and the key precedence, where a silent mistake would be invisible. */
import assert from "node:assert/strict";import { existsSync, readFileSync } from "node:fs";import { readFileSync } from "node:fs";import { dirname, join } from "node:path";import { 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 { ENV_VAR, KEY_FILE, apiKeyConfig } from "./hyper.js";import { API_KEY_CONFIG, ENV_VAR, resolveKey, storedKey } 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[] };
process.env.PI_OFFLINE = "1";delete process.env[ENV_VAR];const configFromFile = apiKeyConfig();process.env[ENV_VAR] = "sk-hyper-test";assert.equal(apiKeyConfig(), ENV_VAR, "HYPER_API_KEY must win over ~/.charm.key");assert.equal( configFromFile, existsSync(KEY_FILE) ? `!cat ${KEY_FILE}` : ENV_VAR, "without HYPER_API_KEY the key file must be read, or nothing must be set", API_KEY_CONFIG, `!printf %s "$${ENV_VAR}"`, "the config fallback must read the env var and resolve to nothing when it is unset",);delete process.env[ENV_VAR];assert.equal(resolveKey(), storedKey(), "without HYPER_API_KEY the stored credential is the source");assert.ok(storedKey() === undefined || !storedKey()?.includes(" "), "a stored key is a single token");assert.ok( storedKey() === undefined || resolveKey() === storedKey(), "nothing but auth.json or the env var may supply a key",);process.env[ENV_VAR] = "sk-hyper-test";assert.equal(resolveKey(), "sk-hyper-test", "HYPER_API_KEY must win over the stored credential");
const registrations: Array<{ name: string; config: ProviderConfig }> = [];const commands: string[] = [];await hyper({59 unmodified lines15 unmodified lines161718192020212223242 unmodified lines2728293031323334353637383931323334353637204 unmodified linesExpand all15 unmodified linesimport { readFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { type ModelFacts, fetchDevModels, parseFactsResponse, toProviderModel } from "./catalog.js";import { API_BASE_URL, KEY_FILE, fetchBalance } from "./hyper.js";import { API_BASE_URL, ENV_VAR, PROVIDER, fetchBalance, resolveKey } from "./hyper.js";import { authorization, getJson } from "./http.js";
const CATALOG_PATH = join(dirname(fileURLToPath(import.meta.url)), "catalog.json");const failures: string[] = [];2 unmodified lines if (!condition) failures.push(message);}
function readApiKey(): string { const fromEnv = process.env.HYPER_API_KEY; if (fromEnv) return fromEnv; try { const fromFile = readFileSync(KEY_FILE, "utf-8").trim(); if (fromFile) return fromFile; } catch { // Falls through to the error below. } throw new Error(`no API key: set HYPER_API_KEY or write ${KEY_FILE}`); const key = resolveKey(); if (!key) throw new Error(`no API key: run /login ${PROVIDER} or set ${ENV_VAR}`); return key;}
const apiKey = readApiKey();const catalog = JSON.parse(readFileSync(CATALOG_PATH, "utf-8")) as { models: ModelFacts[] };204 unmodified linesExpand all