add Charm Hyper provider extension for prime-agent
9e4026de +1874
123node_modules/*.log.DS_Store123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145# prime-charm-hyper
A [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) provider extension for[Charm Hyper](https://hyper.charm.land), Charm's inference service for coding models.
Registers the `hyper` provider with the whole Hyper catalog, device-code subscriptionlogin, API-key auth, and the thinking controls each model advertises.
## Install
```ln -s /Users/dawn/proj/prime-charm-hyper ~/.prime/agent/extensions/prime-charm-hyper```
Edits are picked up by `/reload`; new sessions load it at startup. For a one-off runwithout installing:
```prime-agent -e /Users/dawn/proj/prime-charm-hyper --model hyper/glm-5.3-flash```
## Auth
Resolution order:
1. `/login hyper` - device-code login against `POST /device/auth`, for a Hyper subscription2. `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.
## Why Chat Completions
Hyper exposes three chat endpoints. This extension uses `POST /v1/chat/completions` becauseit is the only one that carries the behaviour an agent needs, measured on the live service:
| | `/v1/chat/completions` | `/v1/messages` | `/v1/responses` || --- | --- | --- | --- || streaming text | yes | yes | yes || tool calls | yes | yes | yes || reasoning visible | `reasoning_content` | `thinking` block | not emitted || can stop thinking | `reasoning_effort: "none"` | no | n/a || prompt-cache accounting | `prompt_tokens_details.cached_tokens` | none | none || usage in stream | yes, with cost | yes | yes |
`/v1/messages` returns a thinking block even when the request omits `thinking`, and reportsno cache fields, so cost accounting would be wrong. `/v1/responses` never emitted areasoning item at any effort level. Both are usable; neither is better for this client.
One endpoint quirk worth knowing: `tool_choice: "required"` and the object form both returnHTTP 400, while `auto` and `none` work. Prime Agent never sends `tool_choice`, so ordinarytool use is unaffected.
## Models
Metadata comes from [models.dev](https://models.dev)'s `hyper` entry, which carries what acoding agent needs and Hyper's own endpoints only partly report: the reasoning effort levelsthis deployment takes, vision support, context and output limits, and prices.`GET /v1/provider` supplies membership and this deployment's own prices, and it agrees withmodels.dev on everything except display names.
`catalog.json` is that join, committed, and read at startup. On load the extension registersthe newest available catalog - the committed one, or the last refresh cached in`~/.prime/agent/hyper-catalog.json` - and then refreshes in the background, re-registering ifthe model list changed. Startup never waits on the network, and a fresh clone has modelsbefore the first request completes.
Regenerate after a model is added or repriced:
```npm run regen # two requests: models.dev and /v1/providernpm run regen -- --dry # print the table without writing```
## Thinking
Hyper's effort levels are per model, and Prime Agent's are not, so each advertised level mapsto the level of the same name; a level the model does not advertise is hidden rather thansent on the chance that Hyper ignores it, because two settings that send the same request areworse than one setting. Models that reason without naming levels (`glm-5`, `kimi-k2-thinking`,`gemma-4-26b`) get a single on state, Prime Agent's `max`; a model that does not reason at allgets no thinking controls.
The off switch is only offered where Hyper advertises one, which arrives as a `none` level andgoes out as `reasoning_effort: "none"`. Everything else sends plain OpenAI fields:`reasoning_effort` for a level, nothing for off. Hyper also accepts a non-standard`thinking: {type: "disabled"}` on many models that do not advertise `none` - measured workingon `deepseek-v4-*` and `glm-5.x`, and a hard 400 on `glm-5.3` and `kimi-k2-thinking` - butthat needs per-model evidence, and the advertised level list is the contract this extensionships against.
## Caching and cost
Prompt caching is implicit and automatic: Hyper bills a repeated prefix as a cache hit(measured at 4x lower cost on the second call) and reports it in`prompt_tokens_details.cached_tokens`, which the openai-completions provider turns into PrimeAgent's `cacheRead` usage. No cache-control markers are sent; `/v1/messages` is where thosewould go, and it does not report cache usage at all.
`/hyper` prints the account's hypercredit balance (`GET /v1/credits`) and the registered modelcount. Hyper extends the standard usage object with `cost.usd`, `cost.hypercredits`, and`remaining.hypercredits`; Prime Agent reads only token counts and the catalog's publishedprices, so the balance in `/hyper` is the live figure.
## Verifying
```npm test # offline: key precedence, registration, catalog -> model config derivationnpm run verify # live: catalog vs /v1/provider and models.dev, streaming, usage, cache, tools, visionnpm run smoke # one real turn through prime-agent with a hyper modelnpm run check # tsc --noEmit against a neighbouring prime-agent checkout```
`verify` costs about twenty requests and takes ten seconds. It checks the derivation, comparesevery catalog model against both live sources, and exercises streaming, usage, cacheaccounting, tool calls, reasoning output, image acceptance and rejection, and the creditendpoint.
The device login was checked as far as it can be without a human: `POST /device/auth` returnsthe code and verification URL this extension renders, and an unapproved code polls as`authorization_pending`. The final token exchange follows the shape the official client usesand needs one approved login to exercise.
## 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.
## Layout
| file | role || --- | --- || `index.ts` | extension entry: registration, background refresh, `/hyper` command || `catalog.ts` | `/v1/provider` + 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 || `http.ts` | JSON transport with timeouts and error extraction || `catalog.json` | committed model list || `regen.ts` | rebuilds `catalog.json` || `test.ts` | offline checks || `verify.ts` | live verification |123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632{ "version": 1, "generatedAt": "2026-09-15T23:14:21.121Z", "models": [ { "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 384000, "levels": [ "high", "xhigh" ], "cost": { "input": 0.2, "output": 0.4, "cacheRead": 0.04, "cacheWrite": 0 } }, { "id": "deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 384000, "levels": [ "low", "high", "max" ], "cost": { "input": 0.44, "output": 1.32, "cacheRead": 0.044, "cacheWrite": 0 } }, { "id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 384000, "levels": [ "high", "xhigh" ], "cost": { "input": 2.4, "output": 4.8, "cacheRead": 0.2, "cacheWrite": 0 } }, { "id": "deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro 0813", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 262144, "levels": [ "low", "high", "max" ], "cost": { "input": 1.437216, "output": 4.311648, "cacheRead": 0.0479072, "cacheWrite": 0 } }, { "id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 32768, "levels": [ "low", "high", "max" ], "cost": { "input": 0.32664, "output": 1.30656, "cacheRead": 0.032664, "cacheWrite": 0 } }, { "id": "gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", "reasoning": true, "vision": false, "contextWindow": 256000, "maxTokens": 25600, "levels": [], "cost": { "input": 0.098, "output": 0.334, "cacheRead": 0.049, "cacheWrite": 0 } }, { "id": "glm-5", "name": "GLM-5", "reasoning": true, "vision": false, "contextWindow": 202752, "maxTokens": 20275, "levels": [], "cost": { "input": 0.86, "output": 2.752, "cacheRead": 0.43, "cacheWrite": 0 } }, { "id": "glm-5.1", "name": "GLM-5.1", "reasoning": true, "vision": false, "contextWindow": 202750, "maxTokens": 3276, "levels": [], "cost": { "input": 1.358, "output": 4.268, "cacheRead": 0.679, "cacheWrite": 0 } }, { "id": "glm-5.2", "name": "GLM-5.2", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 32768, "levels": [ "high", "xhigh" ], "cost": { "input": 1.52432, "output": 4.79072, "cacheRead": 0.152432, "cacheWrite": 0 } }, { "id": "glm-5.3", "name": "GLM-5.3", "reasoning": true, "vision": false, "contextWindow": 1048576, "maxTokens": 128000, "levels": [ "low", "high", "max" ], "cost": { "input": 1.52432, "output": 4.79072, "cacheRead": 0.283088, "cacheWrite": 0 } }, { "id": "glm-5.3-flash", "name": "GLM-5.3-Flash", "reasoning": true, "vision": true, "contextWindow": 1048576, "maxTokens": 131072, "levels": [ "low", "high", "max" ], "cost": { "input": 0.16332, "output": 0.5444, "cacheRead": 0.031575200000000005, "cacheWrite": 0 } }, { "id": "gpt-oss-120b", "name": "GPT OSS 120B", "reasoning": true, "vision": false, "contextWindow": 128072, "maxTokens": 13107, "levels": [ "none", "minimal", "low", "medium", "high", "xhigh", "max" ], "cost": { "input": 0.178, "output": 0.68, "cacheRead": 0.089, "cacheWrite": 0 } }, { "id": "inkling", "name": "Inkling", "reasoning": true, "vision": true, "contextWindow": 1048576, "maxTokens": 32768, "levels": [ "none", "minimal", "low", "medium", "high", "xhigh" ], "cost": { "input": 1.0888, "output": 4.40964, "cacheRead": 0.185096, "cacheWrite": 0 } }, { "id": "kimi-k2-thinking", "name": "Kimi K2 Thinking", "reasoning": true, "vision": false, "contextWindow": 262144, "maxTokens": 26214, "levels": [], "cost": { "input": 0.6, "output": 2.5, "cacheRead": 0.3, "cacheWrite": 0 } }, { "id": "kimi-k2.5", "name": "Kimi K2.5", "reasoning": true, "vision": false, "contextWindow": 262144, "maxTokens": 26214, "levels": [], "cost": { "input": 0.5584, "output": 2.935, "cacheRead": 0.2792, "cacheWrite": 0 } }, { "id": "kimi-k2.6", "name": "Kimi K2.6", "reasoning": true, "vision": true, "contextWindow": 262000, "maxTokens": 26214, "levels": [ "low", "medium", "high" ], "cost": { "input": 1.03436, "output": 4.3552, "cacheRead": 0.174208, "cacheWrite": 0 } }, { "id": "kimi-k2.7-code", "name": "Kimi K2.7 Code", "reasoning": true, "vision": true, "contextWindow": 256000, "maxTokens": 16000, "levels": [], "cost": { "input": 1.03436, "output": 4.3552, "cacheRead": 0.206872, "cacheWrite": 0 } }, { "id": "kimi-k3", "name": "Kimi K3", "reasoning": true, "vision": true, "contextWindow": 1048576, "maxTokens": 16000, "levels": [ "low", "high", "max" ], "cost": { "input": 3.2664, "output": 16.332, "cacheRead": 0.32664, "cacheWrite": 0 } }, { "id": "llama-3.3-70b-instruct", "name": "Llama-3.3-70B-Instruct", "reasoning": false, "vision": false, "contextWindow": 128000, "maxTokens": 12800, "levels": [], "cost": { "input": 0.6066, "output": 1.0386, "cacheRead": 0.3033, "cacheWrite": 0 } }, { "id": "llama-4-maverick-17b-128e-instruct-fp8", "name": "Llama 4 Maverick 17B Instruct", "reasoning": false, "vision": false, "contextWindow": 430000, "maxTokens": 43000, "levels": [], "cost": { "input": 0.25188, "output": 0.82563, "cacheRead": 0.12594, "cacheWrite": 0 } }, { "id": "minimax-m2.7", "name": "MiniMax-M2.7", "reasoning": true, "vision": false, "contextWindow": 262100, "maxTokens": 6553, "levels": [], "cost": { "input": 0.418, "output": 1.588, "cacheRead": 0.209, "cacheWrite": 0 } }, { "id": "minimax-m3", "name": "MiniMax-M3", "reasoning": true, "vision": true, "contextWindow": 512000, "maxTokens": 512000, "levels": [ "low", "medium", "high" ], "cost": { "input": 0.32664, "output": 1.30656, "cacheRead": 0.0642392, "cacheWrite": 0 } }, { "id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar", "name": "Qwen3-Coder 480B-A35B Instruct", "reasoning": false, "vision": false, "contextWindow": 106000, "maxTokens": 10600, "levels": [], "cost": { "input": 0.445, "output": 2.145, "cacheRead": 0.2225, "cacheWrite": 0 } }, { "id": "qwen3-next-80b-a3b-instruct", "name": "Qwen3-Next 80B-A3B Instruct", "reasoning": false, "vision": false, "contextWindow": 262144, "maxTokens": 26214, "levels": [], "cost": { "input": 0.1175, "output": 1.136, "cacheRead": 0.05875, "cacheWrite": 0 } }, { "id": "qwen3.6-flash", "name": "Qwen3.6 Flash", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 1, "output": 4, "cacheRead": 0.1, "cacheWrite": 1.25 } }, { "id": "qwen3.6-max", "name": "Qwen3.6 Max Preview", "reasoning": true, "vision": false, "contextWindow": 256000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 2, "output": 12, "cacheRead": 0.2, "cacheWrite": 2.5 } }, { "id": "qwen3.6-plus", "name": "Qwen3.6 Plus", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 2, "output": 6, "cacheRead": 0.2, "cacheWrite": 2.5 } }, { "id": "qwen3.7-flash", "name": "Qwen3.7 Flash", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 0.2, "output": 0.8, "cacheRead": 0.04, "cacheWrite": 0 } }, { "id": "qwen3.7-max", "name": "Qwen3.7 Max", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 2.5, "output": 7.5, "cacheRead": 0.5, "cacheWrite": 0 } }, { "id": "qwen3.7-plus", "name": "Qwen3.7 Plus", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 64000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 1.2, "output": 4.8, "cacheRead": 0.24, "cacheWrite": 0 } }, { "id": "qwen3.8-2.4t-a95b", "name": "Qwen3.8 2.4T A95B", "reasoning": true, "vision": false, "contextWindow": 1000000, "maxTokens": 128000, "levels": [ "minimal", "low", "medium", "high" ], "cost": { "input": 2, "output": 6, "cacheRead": 0.25, "cacheWrite": 0 } }, { "id": "qwen3.8-27b", "name": "Qwen3.8 27B", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 128000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 0.5, "output": 3, "cacheRead": 0.1, "cacheWrite": 0 } }, { "id": "qwen3.8-flash", "name": "Qwen3.8 Flash", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 128000, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 0.15, "output": 0.47, "cacheRead": 0.016, "cacheWrite": 0 } }, { "id": "qwen3.8-max", "name": "Qwen3.8 Max Preview", "reasoning": true, "vision": true, "contextWindow": 1000000, "maxTokens": 65536, "levels": [ "none", "minimal", "low", "medium", "high" ], "cost": { "input": 2, "output": 6, "cacheRead": 0.25, "cacheWrite": 0 } } ], "source": "https://models.dev/api.json + GET /v1/provider"}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283/** * The Hyper model catalog. * * Metadata comes from models.dev's `hyper` provider entry, which carries what a coding * agent needs and what Hyper's own endpoints only partly report: the reasoning effort * levels the deployment takes, vision support, context and output limits, and prices. * `GET /v1/provider` supplies membership and this deployment's own prices, and decides * which models exist right now. * * `catalog.json` is that join, committed. The runtime loads the newest of it and the last * refresh cached in ~/.prime/agent/hyper-catalog.json, registers immediately, and * refreshes in the background. Parsing is strict: a shape we do not understand leaves the * previous catalog in place instead of registering models with wrong prices. */
import { readFileSync, writeFileSync } from "node:fs";import type { OpenAICompletionsCompat } from "@earendil-works/pi-ai";import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";import { API_BASE_URL, cachePath } from "./hyper.js";import { getJson } from "./http.js";
export const CATALOG_VERSION = 1;export const CATALOG_SOURCE = "https://models.dev/api.json + GET /v1/provider";
/** Prime Agent's thinking levels, ascending. Hyper's effort levels use the same names. */export const PI_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
/** * The level offered for a reasoning model that advertises no effort levels. Such a model * thinks by default and takes an effort without naming one, so the top level stands in for * "thinking on". */export const UNADVERTISED_ON_LEVEL = "max";
export interface ModelCost { /** Dollars per million tokens, as Prime Agent prices models. */ input: number; output: number; cacheRead: number; cacheWrite: number;}
export interface ModelFacts { id: string; name: string; reasoning: boolean; vision: boolean; contextWindow: number; maxTokens: number; /** Hyper effort levels; empty when the model names none. */ levels: string[]; cost: ModelCost;}
export interface Catalog { version: number; generatedAt: string; models: ModelFacts[];}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const at = (source: Record<string, unknown>, name: string): unknown => Object.getOwnPropertyDescriptor(source, name)?.value;
function requireString(source: Record<string, unknown>, name: string, where: string): string { const value = at(source, name); if (typeof value !== "string" || value === "") throw new Error(`${where} has no string "${name}"`); return value;}
function requireNumber(source: Record<string, unknown>, name: string, where: string): number { const value = at(source, name); if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${where} has no number "${name}"`); return value;}
function requireBoolean(source: Record<string, unknown>, name: string, where: string): boolean { const value = at(source, name); if (typeof value !== "boolean") throw new Error(`${where} has no boolean "${name}"`); return value;}
const optional = (source: Record<string, unknown>, name: string): unknown => at(source, name);
const optionalNumber = (source: Record<string, unknown>, name: string): number => { const value = at(source, name); return typeof value === "number" && Number.isFinite(value) ? value : 0;};
const optionalBoolean = (source: Record<string, unknown>, name: string): boolean => at(source, name) === true;
const strings = (value: unknown, where: string, name: string): string[] => { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error(`${where} has a non-array "${name}"`); return value.filter((entry): entry is string => typeof entry === "string");};
/** `none`/`off` in Hyper's level list is the off switch, not a thinking depth. */export const isOffLevel = (level: string): boolean => level === "none" || level === "off";
/** The effort levels a models.dev entry advertises. */const devLevels = (model: Record<string, unknown>): string[] => { const options = optional(model, "reasoning_options"); if (!Array.isArray(options)) return []; for (const option of options) { if (isRecord(option) && optional(option, "type") === "effort") return strings(optional(option, "values"), "models.dev", "values"); } return [];};
const devVision = (model: Record<string, unknown>): boolean => { const modalities = optional(model, "modalities"); const input = isRecord(modalities) ? strings(optional(modalities, "input"), "models.dev", "input") : []; return optionalBoolean(model, "attachment") || input.includes("image");};
/** * One model of `GET /v1/provider`, enriched from models.dev. Prices and limits come from * whichever source states them; Hyper's own answer wins where the two disagree, since * those are the prices this key is billed at. */function parseFacts(raw: unknown, index: number, dev: Record<string, unknown> | undefined): ModelFacts { const where = `Hyper /provider model ${index}`; if (!isRecord(raw)) throw new Error(`${where} is not an object`); const id = requireString(raw, "id", where); const devCost = dev && isRecord(optional(dev, "cost")) ? (optional(dev, "cost") as Record<string, unknown>) : undefined; const devLimit = dev && isRecord(optional(dev, "limit")) ? (optional(dev, "limit") as Record<string, unknown>) : undefined; const cost = (name: string, fallback: number): number => { const live = optionalNumber(raw, name); if (live !== 0 || fallback === 0) return live; return fallback; }; return { id, name: dev ? requireString(dev, "name", `models.dev ${id}`) : requireString(raw, "name", where), reasoning: dev ? requireBoolean(dev, "reasoning", `models.dev ${id}`) : requireBoolean(raw, "can_reason", where), vision: dev ? devVision(dev) : requireBoolean(raw, "supports_attachments", where), contextWindow: devLimit ? (optionalNumber(devLimit, "context") || requireNumber(raw, "context_window", where)) : requireNumber(raw, "context_window", where), maxTokens: devLimit ? (optionalNumber(devLimit, "output") || requireNumber(raw, "default_max_tokens", where)) : requireNumber(raw, "default_max_tokens", where), levels: dev ? devLevels(dev) : strings(optional(raw, "reasoning_levels"), where, "reasoning_levels"), cost: { input: cost("cost_per_1m_in", devCost ? optionalNumber(devCost, "input") : 0), output: cost("cost_per_1m_out", devCost ? optionalNumber(devCost, "output") : 0), cacheRead: cost("cost_per_1m_out_cached", devCost ? optionalNumber(devCost, "cache_read") : 0), cacheWrite: cost("cost_per_1m_in_cached", devCost ? optionalNumber(devCost, "cache_write") : 0), }, };}
export function parseFactsResponse(payload: unknown, devModels: Record<string, unknown>): ModelFacts[] { const models = isRecord(payload) ? at(payload, "models") : undefined; if (!Array.isArray(models) || models.length === 0) throw new Error("Hyper /provider returned no models"); return models.map((model, index) => { const id = isRecord(model) ? optional(model, "id") : undefined; const dev = typeof id === "string" ? devModels[id] : undefined; return parseFacts(model, index, isRecord(dev) ? dev : undefined); });}
/** models.dev's `hyper` provider entry. Its absence is not fatal: the live answer suffices. */export async function fetchDevModels(): Promise<Record<string, unknown>> { try { const payload = await getJson("https://models.dev/api.json", undefined, 20_000); const hyper = isRecord(payload) ? optional(payload, "hyper") : undefined; const models = isRecord(hyper) ? optional(hyper, "models") : undefined; return isRecord(models) ? models : {}; } catch { return {}; }}
export const fetchFacts = async (): Promise<ModelFacts[]> => parseFactsResponse(await getJson(`${API_BASE_URL}/provider`), await fetchDevModels());
/** One model row of a catalog file. */function parseCatalogModel(raw: unknown, index: number): ModelFacts { const where = `Catalog model ${index}`; if (!isRecord(raw)) throw new Error(`${where} is not an object`); const cost = optional(raw, "cost"); if (!isRecord(cost)) throw new Error(`${where} has no cost object`); return { id: requireString(raw, "id", where), name: requireString(raw, "name", where), reasoning: requireBoolean(raw, "reasoning", where), vision: requireBoolean(raw, "vision", where), contextWindow: requireNumber(raw, "contextWindow", where), maxTokens: requireNumber(raw, "maxTokens", where), levels: strings(optional(raw, "levels"), where, "levels"), cost: { input: requireNumber(cost, "input", `${where} cost`), output: requireNumber(cost, "output", `${where} cost`), cacheRead: requireNumber(cost, "cacheRead", `${where} cost`), cacheWrite: requireNumber(cost, "cacheWrite", `${where} cost`), }, };}
/** Reads a catalog file, or `undefined` when it is missing, unreadable, or from another version. */export function readCatalog(path: string = cachePath()): Catalog | undefined { try { const payload: unknown = JSON.parse(readFileSync(path, "utf-8")); if (!isRecord(payload) || at(payload, "version") !== CATALOG_VERSION) return undefined; const raw = at(payload, "models"); if (!Array.isArray(raw) || raw.length === 0) return undefined; const generatedAt = at(payload, "generatedAt"); return { version: CATALOG_VERSION, generatedAt: typeof generatedAt === "string" ? generatedAt : "", models: raw.map(parseCatalogModel), }; } catch { return undefined; }}
export const toCatalog = (models: readonly ModelFacts[]): Catalog => ({ version: CATALOG_VERSION, generatedAt: new Date().toISOString(), models: [...models],});
export function writeCatalog(path: string, catalog: Catalog): void { writeFileSync(path, `${JSON.stringify(catalog, null, "\t")}\n`);}
/** * The thinking controls Prime Agent should offer for one model. * * Only advertised levels are offered: Hyper accepts an unadvertised effort silently, so * offering levels it ignores would give the user two entries that send the same request. * A model that reasons without naming levels gets a single on state, and an off switch * only when Hyper advertises one (`none`), which is the shape that reaches * `reasoning_effort`. */function thinkingLevelMap(model: ModelFacts): NonNullable<ProviderModelConfig["thinkingLevelMap"]> | undefined { if (!model.reasoning) return undefined; const advertised = new Set(model.levels); const offered = new Set([...advertised].filter((level) => !isOffLevel(level))); if (offered.size === 0) offered.add(UNADVERTISED_ON_LEVEL); const map: NonNullable<ProviderModelConfig["thinkingLevelMap"]> = { off: [...advertised].some(isOffLevel) ? "none" : null, }; for (const level of PI_LEVELS) map[level] = offered.has(level) ? level : null; return map;}
/** A Hyper model config, with the compat shape every Hyper model uses stated exactly. */export type HyperProviderModel = ProviderModelConfig & { compat: OpenAICompletionsCompat };
export function toProviderModel(model: ModelFacts): HyperProviderModel { const levels = thinkingLevelMap(model); return { id: model.id, name: model.name, api: "openai-completions", reasoning: model.reasoning, input: model.vision ? ["text", "image"] : ["text"], cost: model.cost, contextWindow: model.contextWindow, maxTokens: model.maxTokens, ...(levels ? { thinkingLevelMap: levels } : {}), compat: { // Hyper is a router in front of many upstreams: no response store, `max_tokens`, // and plain OpenAI reasoning controls. supportsStore: false, supportsReasoningEffort: model.reasoning, maxTokensField: "max_tokens", thinkingFormat: "openai", }, };}
export const toProviderModels = (models: readonly ModelFacts[]): HyperProviderModel[] => models.map(toProviderModel);
export const sameModels = (a: readonly ProviderModelConfig[], b: readonly ProviderModelConfig[]): boolean => JSON.stringify(a) === JSON.stringify(b);123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354/** * Minimal JSON transport for Hyper's non-streaming endpoints, with timeouts and * error details taken from the OpenAI/Anthropic error envelopes Hyper returns. */
const TIMEOUT_MS = 10_000;
export class HttpError extends Error { readonly status: number;
constructor(status: number, url: string, detail: string) { super(`${url} returned HTTP ${status}${detail ? `: ${detail}` : ""}`); this.name = "HttpError"; this.status = status; }}
async function request(url: string, init: RequestInit, timeoutMs: number): Promise<unknown> { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); const body = await response.text(); if (!response.ok) throw new HttpError(response.status, url, errorDetail(body)); 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. */function errorDetail(body: string): string { try { const payload = JSON.parse(body) as { error?: unknown }; const error = (payload.error ?? {}) as { message?: unknown; error?: { message?: unknown } }; const message = error.message ?? error.error?.message; return typeof message === "string" ? message : ""; } catch { return body.slice(0, 120).trim(); }}
const jsonHeaders = (extra?: Record<string, string>): Record<string, string> => ({ "Content-Type": "application/json", ...extra,});
export const getJson = (url: string, headers?: Record<string, string>, timeoutMs = TIMEOUT_MS): Promise<unknown> => request(url, { method: "GET", headers: jsonHeaders(headers) }, timeoutMs);
export const postJson = ( url: string, body: unknown, headers?: Record<string, string>, timeoutMs = TIMEOUT_MS,): Promise<unknown> => request(url, { method: "POST", headers: jsonHeaders(headers), body: JSON.stringify(body) }, timeoutMs);
export const authorization = (apiKey: string): Record<string, string> => ({ Authorization: `Bearer ${apiKey}` });12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455/** * Charm Hyper (https://hyper.charm.land) provider identity and auth for Prime Agent. */
import { existsSync } from "node:fs";import { getJson } from "./http.js";import { homedir } from "node:os";import { join } from "node:path";
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");
/** * 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 { const configured = process.env.PRIME_AGENT_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR; if (!configured) return join(homedir(), ".prime", "agent"); if (configured === "~") return homedir(); return configured.startsWith("~/") ? join(homedir(), configured.slice(2)) : configured;}
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. */export function apiKeyConfig(): string { if (process.env[ENV_VAR]) return ENV_VAR; return existsSync(KEY_FILE) ? `!cat ${KEY_FILE}` : ENV_VAR;}
/** 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; for (const field of ["balance", "balance_usd"]) { const value = Object.getOwnPropertyDescriptor(payload, field)?.value; if (typeof value === "number") return value; } return undefined;}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123/** * 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 precedence: * 1. `/login hyper` -> device-code subscription login * 2. HYPER_API_KEY * 3. ~/.charm.key * * Models come from `GET /v1/provider` 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, DISPLAY_NAME, PROVIDER, apiKeyConfig, 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: apiKeyConfig(), 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 or ~/.charm.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)}`); });}12345678910111213141516171819{ "name": "prime-charm-hyper", "private": true, "version": "0.1.0", "type": "module", "description": "Prime Agent provider extension for Charm Hyper (https://hyper.charm.land)", "pi": { "extensions": [ "./index.ts" ] }, "scripts": { "test": "npx tsx test.ts", "regen": "npx tsx regen.ts", "verify": "npx tsx verify.ts", "check": "../prime-agent/node_modules/.bin/tsc -p tsconfig.json", "smoke": "prime-agent -e . --no-session -p --model hyper/qwen3.8-flash --thinking off 'reply with the single word: ok'" }}