/**
* 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/models` - public, documented - 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/models";
/** 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/models`, 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 /models model ${index}`;
if (!isRecord(raw)) throw new Error(`${where} is not an object`);
const id = requireString(raw, "id", where);
const pricing = isRecord(optional(raw, "pricing")) ? (optional(raw, "pricing") as Record<string, unknown>) : {};
const capabilities = isRecord(optional(raw, "capabilities")) ? (optional(raw, "capabilities") as Record<string, unknown>) : {};
const reasoning = isRecord(optional(raw, "reasoning")) ? (optional(raw, "reasoning") as Record<string, unknown>) : undefined;
const levels = reasoning ? strings(optional(reasoning, "effort_levels"), where, "effort_levels") : [];
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;
// /v1/models reports effort levels as objects; keep the names.
const namedLevels = levels
.map((level) => (isRecord(level) ? optional(level, "value") : level))
.filter((level): level is string => typeof level === "string");
return {
id,
name: dev ? requireString(dev, "name", `models.dev ${id}`) : requireString(raw, "display_name", where),
reasoning: dev ? requireBoolean(dev, "reasoning", `models.dev ${id}`) : reasoning !== undefined,
vision: dev ? devVision(dev) : optionalBoolean(capabilities, "vision"),
contextWindow: devLimit
? (optionalNumber(devLimit, "context") || requireNumber(raw, "context_window", where))
: requireNumber(raw, "context_window", where),
maxTokens: devLimit
? (optionalNumber(devLimit, "output") || requireNumber(raw, "max_output_tokens", where))
: requireNumber(raw, "max_output_tokens", where),
levels: dev ? devLevels(dev) : namedLevels,
cost: {
input: optionalNumber(pricing, "input") || (devCost ? optionalNumber(devCost, "input") : 0),
output: optionalNumber(pricing, "output") || (devCost ? optionalNumber(devCost, "output") : 0),
cacheRead: optionalNumber(pricing, "cache_hit") || (devCost ? optionalNumber(devCost, "cache_read") : 0),
cacheWrite: optionalNumber(pricing, "cache_create") || (devCost ? optionalNumber(devCost, "cache_write") : 0),
},
};
}
export function parseFactsResponse(payload: unknown, devModels: Record<string, unknown>): ModelFacts[] {
const models = isRecord(payload) ? at(payload, "data") : undefined;
if (!Array.isArray(models) || models.length === 0) throw new Error("Hyper /v1/models 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}/models`), 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;
}
}
/** Sorted by id: the endpoint's order is not stable, and registration order follows this. */
export const toCatalog = (models: readonly ModelFacts[]): Catalog => ({
version: CATALOG_VERSION,
generatedAt: new Date().toISOString(),
models: [...models].sort((a, b) => a.id.localeCompare(b.id)),
});
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);