From 5a78af032f7b62ff5f7ab1def1613e5f870813c0 Mon Sep 17 00:00:00 2001 From: Roy Kollen Svendsen Date: Sat, 3 Oct 2026 13:02:18 +0200 Subject: [PATCH] feat(engy): add engy sync module engy publishes prices, the context window and modalities on a public GET /v1/models; the module syncs those and preserves everything else (the input/output split, reasoning_options, interleaved, the other cost keys, provider, experimental, status, base_model). It never creates or deletes: a created file would ship limit.output = 0, so unseen ids become [missing-model] issues, and the unauthenticated list can return an empty data array, so missing files are retained and reported. An authored base_model wins over the slug resolver. Prices round to six decimals, so the hourly run is a no-op. A context_length that no longer equals the authored input + output leaves the file alone and opens a [missing-model] issue, since the public list cannot say which half moved. A price that is not a non-negative number fails the run instead of writing a free model, an empty or unrecognised modality list keeps the authored one, and a row with an empty id is ignored. Registered in sync/index.ts and the direct group, with an engy:sync script, an engy Notes section in sync.md, and 25 tests that read the shipped files. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018k8EL6s5hEtTTMJ8Jjd4Ls --- package.json | 1 + packages/core/src/sync/index.ts | 5 +- packages/core/src/sync/providers/engy.ts | 173 ++++++++++ packages/core/test/engy.test.ts | 409 +++++++++++++++++++++++ sync.md | 16 + 5 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/sync/providers/engy.ts create mode 100644 packages/core/test/engy.test.ts diff --git a/package.json b/package.json index 04386e428e4..05435ff8751 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "deepinfra:sync": "bun ./packages/core/script/sync-models.ts deepinfra", "cloudflare:sync": "bun ./packages/core/script/sync-models.ts cloudflare-workers-ai", "chutes:sync": "bun ./packages/core/script/sync-models.ts chutes", + "engy:sync": "bun ./packages/core/script/sync-models.ts engy", "databricks:generate": "bun ./packages/core/script/generate-databricks.ts", "helicone:generate": "bun ./packages/core/script/generate-helicone.ts", "cloudflare-ai-gateway:generate": "bun ./packages/core/script/generate-cloudflare-ai-gateway.ts", diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index ed780553a0f..c0b07034419 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -19,6 +19,7 @@ import { deepinfra } from "./providers/deepinfra.js"; import { digitalocean } from "./providers/digitalocean.js"; import { edenai } from "./providers/edenai.js"; import { empiriolabs } from "./providers/empiriolabs.js"; +import { engy } from "./providers/engy.js"; import { fireworksAi } from "./providers/fireworks-ai.js"; import { friendli } from "./providers/friendli.js"; import { githubCopilot } from "./providers/github-copilot.js"; @@ -154,6 +155,7 @@ export const providers: { digitalocean: SyncProvider; edenai: SyncProvider; empiriolabs: SyncProvider; + engy: SyncProvider; "fireworks-ai": SyncProvider; friendli: SyncProvider; "github-copilot": SyncProvider; @@ -194,6 +196,7 @@ export const providers: { digitalocean, edenai, empiriolabs, + engy, "fireworks-ai": fireworksAi, friendli, "github-copilot": githubCopilot, @@ -240,7 +243,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "mistral", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "engy", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "mistral", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/engy.ts b/packages/core/src/sync/providers/engy.ts new file mode 100644 index 00000000000..8418263d4b2 --- /dev/null +++ b/packages/core/src/sync/providers/engy.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; + +import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; + +const API_ENDPOINT = "https://api.engy.ai/v1/models"; + +// Per-token USD, usually a string. An empty or non-numeric string must fail the +// run, not be written as a free model. +const Price = z.union([ + z.number().nonnegative(), + z.string().regex(/^\d+(\.\d+)?(e[-+]?\d+)?$/i, "engy price is not a non-negative number"), +]); + +const Pricing = z + .object({ + prompt: Price.optional(), + completion: Price.optional(), + input_cache_read: Price.optional(), + }) + .passthrough(); + +export const EngyModel = z + .object({ + id: z.string(), + pricing: Pricing.optional(), + context_length: z.number().int().optional(), + max_model_len: z.number().int().optional(), + input_modalities: z.array(z.string()).optional(), + output_modalities: z.array(z.string()).optional(), + }) + .passthrough(); + +export const EngyResponse = z.object({ data: z.array(EngyModel) }).passthrough(); + +export type EngyModel = z.infer; + +type Modality = "text" | "audio" | "image" | "video" | "pdf"; + +export const engy = { + id: "engy", + name: "engy", + modelsDir: "providers/engy/models", + // The public list omits the input/output split, so a created file would ship + // limit.output = 0. Unseen ids are reported for a human to author. + skipCreates: true, + trackMissingModels: true, + // The list is unauthenticated and an empty `data` passes the schema; one + // truncated 200 must not delete the hand-measured files. + deleteMissing: false, + sourceID(model) { + return model.id; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} engy models have no local catalog file and were not created because the public list omits the input/output split; author them after measuring the cap: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + missingNotice(paths) { + if (paths.length === 0) return []; + return [ + `${paths.length} local engy models are missing from the public list and were retained; a human removes retired models: ${paths.map((path) => `\`${path}\``).join(", ")}`, + ]; + }, + async fetchModels() { + return fetchEngyModels(); + }, + parseModels(raw) { + return parseEngyModels(raw); + }, + translateModel(model, context) { + return { + id: model.id, + model: buildEngyModel(model, context.existing(model.id)), + }; + }, +} satisfies SyncProvider; + +export async function fetchEngyModels(fetcher: typeof fetch = fetch) { + const response = await fetcher(API_ENDPOINT); + if (!response.ok) { + throw new Error(`engy models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export function parseEngyModels(raw: unknown): EngyModel[] { + const rows = EngyResponse.parse(raw).data; + // A row without an id maps to no file and would open a nameless missing-model issue. + const unnamed = rows.filter((model) => model.id.trim() === "").length; + if (unnamed > 0) console.warn(`engy: ignored ${unnamed} model row(s) with an empty id`); + return rows.filter((model) => model.id.trim() !== ""); +} + +export function buildEngyModel(model: EngyModel, existing: ExistingModel | undefined): SyncedModel { + // An absent, empty or unrecognised modality list means "not reported", not text-only. + const input = normalizeModalities(model.input_modalities) ?? existing?.modalities?.input ?? ["text"]; + const output = normalizeModalities(model.output_modalities) ?? existing?.modalities?.output ?? ["text"]; + + // A non-positive window is a bad row, not a smaller window. + const apiContext = model.context_length ?? model.max_model_len ?? 0; + const context = apiContext > 0 ? apiContext : existing?.limit?.context ?? 0; + const authoredInput = existing?.limit?.input; + const authoredOutput = existing?.limit?.output; + if (apiContext > 0 && authoredInput !== undefined && authoredOutput !== undefined + && apiContext !== authoredInput + authoredOutput) { + // The new window means the authored split is stale, and the public list cannot + // say which half moved. Leave the file alone and open an issue for a human. + throw new MissingReasoningOptionsError( + model.id, + `engy's context_length is now ${apiContext}, but the authored limit.input + limit.output is ${authoredInput + authoredOutput}; re-read max_input_tokens and max_output_tokens from the authenticated https://engy.ai/api/v1/models`, + ); + } + const limit = { + context, + // engy's context is max_input + max_output, so the split stays hand-authored. + input: existing?.limit?.input, + output: existing?.limit?.output ?? 0, + }; + + const cost = + model.pricing?.prompt !== undefined && model.pricing?.completion !== undefined + ? { + ...existing?.cost, + input: perMillion(model.pricing.prompt), + output: perMillion(model.pricing.completion), + cache_read: + model.pricing.input_cache_read === undefined + ? existing?.cost?.cache_read + : perMillion(model.pricing.input_cache_read), + } + : existing?.cost; + + // Keep every authored field and overwrite only what the list is authoritative for. + const { base_model: authoredBase, base_model_omit: baseModelOmit, ...current } = existing ?? {}; + const values = { + ...current, + name: current.name ?? model.id, + attachment: input.some((value) => value !== "text"), + cost, + limit, + modalities: { input, output }, + } as Parameters[1]; + + // An authored pointer wins: a resolver miss (absent or ambiguous slug) must + // not de-factor a committed file. + const baseModel = authoredBase ?? resolveModelMetadataBaseModel(model.id); + return baseModel === undefined + ? (values as SyncedModel) + : factorBaseModel(baseModel, values, limit, baseModelOmit); +} + +// Wire prices are per-token USD strings. Two of them pick up float error when +// multiplied to per-1M (0.00000068 * 1e6), so round to micro-dollars. +function perMillion(value: string | number): number { + return Math.round(Number(value) * 1_000_000 * 1e6) / 1e6; +} + +function normalizeModalities(values: string[] | undefined): Modality[] | undefined { + if (values === undefined) return undefined; + const allowed = new Set(["text", "audio", "image", "video", "pdf"]); + const result = values + .map((value) => value.toLowerCase()) + .map((value) => value === "file" ? "pdf" : value) + .filter((value): value is Modality => allowed.has(value as Modality)); + const unique = [...new Set(result)]; + if (unique.length === 0) return undefined; + // Wire order is arbitrary; sort so a rewrite for any other reason keeps catalogue order. + const order: Modality[] = ["text", "image", "audio", "video", "pdf"]; + return unique.sort((a, b) => order.indexOf(a) - order.indexOf(b)); +} diff --git a/packages/core/test/engy.test.ts b/packages/core/test/engy.test.ts new file mode 100644 index 00000000000..cc48671a00a --- /dev/null +++ b/packages/core/test/engy.test.ts @@ -0,0 +1,409 @@ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { mergeDeep } from "remeda"; + +import { syncProvider, type ExistingModel, type SyncedModel, type SyncProvider } from "../src/sync/index.js"; +import { groups, providers } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; +import { buildEngyModel, engy, fetchEngyModels, type EngyModel } from "../src/sync/providers/engy.js"; +import { modelMetadata } from "../src/sync/providers/openrouter.js"; + +const REPO = path.join(import.meta.dirname, "..", "..", ".."); +const SHIPPED_DIR = path.join(REPO, "providers", "engy", "models"); +const LAB_DIR = path.join(REPO, "models"); + +// Two rows of GET https://api.engy.ai/v1/models (2026-08-31), trimmed to the +// fields the sync reads. Prices are per-token USD strings, two of which are +// inexact once multiplied to per-1M, and modalities come image-first. +const PUBLIC_LIST = { + object: "list", + data: [ + { + id: "glm-5.2", + input_modalities: ["text"], + output_modalities: ["text"], + context_length: 262_144, + pricing: { prompt: "0.00000068", completion: "0.0000015", input_cache_read: "0.00000018" }, + }, + { + id: "glm-5.3-flash", + input_modalities: ["image", "text"], + output_modalities: ["text"], + context_length: 262_144, + pricing: { prompt: "0.000000135", completion: "0.00000045", input_cache_read: "0.000000027" }, + }, + ], +}; +const [GLM52_ROW, FLASH_ROW] = engy.parseModels(PUBLIC_LIST) as [EngyModel, EngyModel]; + +function perToken(usdPerMillion: number | undefined) { + return usdPerMillion === undefined ? undefined : (usdPerMillion / 1e6).toFixed(12).replace(/0+$/, ""); +} + +// The row engy's list has to serve for the sync to leave a file alone. +function wireRow(id: string, model: ExistingModel, prompt = perToken(model.cost?.input)): EngyModel { + return { + id, + // engy lists image before text. + input_modalities: [...(model.modalities?.input ?? [])].reverse(), + output_modalities: model.modalities?.output, + context_length: model.limit?.context, + pricing: { prompt, completion: perToken(model.cost?.output), input_cache_read: perToken(model.cost?.cache_read) }, + }; +} + +// Every shipped file as the runner sees it: the text, its comment header, the +// file merged over its lab base, and its wire row. Read from the file so an +// hourly repricing cannot make the round trips stale. +type Shipped = { text: string; header: string; resolved: ExistingModel; wire: EngyModel }; + +const SHIPPED: Record = Object.fromEntries( + readdirSync(SHIPPED_DIR).map((file) => { + const id = file.slice(0, -".toml".length); + const text = readFileSync(path.join(SHIPPED_DIR, file), "utf8"); + const authored = Bun.TOML.parse(text) as ExistingModel; + const { benchmarks: _benchmarks, weights: _weights, ...base } = modelMetadata(authored.base_model!); + const resolved = mergeDeep(base, authored) as ExistingModel; + return [id, { text, header: text.slice(0, text.indexOf("base_model =")), resolved, wire: wireRow(id, resolved) }]; + }), +); +const GLM52 = SHIPPED["glm-5.2"]!; +const KIMI_K3 = SHIPPED["kimi-k3"]!; +// The same file with no pointer: what the runner hands over for an inline file. +const { base_model: _pointer, ...GLM52_INLINE } = GLM52.resolved; + +function baseModelOf(model: SyncedModel) { + return "base_model" in model ? model.base_model : undefined; +} + +test("parses the public list", () => { + const models = engy.parseModels(PUBLIC_LIST); + expect(models.map((model) => model.id)).toEqual(["glm-5.2", "glm-5.3-flash"]); + expect(models.map((model) => engy.sourceID(model))).toEqual(["glm-5.2", "glm-5.3-flash"]); +}); + +test("is update-only: no creates, no deletes, a notice for each", () => { + expect(engy).toMatchObject({ skipCreates: true, trackMissingModels: true, deleteMissing: false }); + // The runner's preserveBaseModel step stays on, so a translated model that + // lost its pointer gets it back from the existing file. + expect((engy as SyncProvider).preserveBaseModels).toBeUndefined(); + expect(engy.skippedNotice([])).toEqual([]); + expect(engy.missingNotice([])).toEqual([]); +}); + +test("converts per-token price strings to per-1M and rounds away the float error", () => { + // Unrounded, these two files would be rewritten on every hourly run. + expect(Number("0.00000068") * 1e6).toBe(0.6799999999999999); + expect(Number("0.00000045") * 1e6).toBe(0.44999999999999996); + expect(buildEngyModel(GLM52_ROW, undefined).cost).toEqual({ input: 0.68, output: 1.5, cache_read: 0.18 }); + expect(buildEngyModel(FLASH_ROW, undefined).cost).toEqual({ input: 0.135, output: 0.45, cache_read: 0.027 }); +}); + +test("writes modalities in catalog order, de-duplicated, unknown names dropped", () => { + // Factored against zhipuai/glm-5.3-flash, whose output already matches. + expect(buildEngyModel(FLASH_ROW, undefined).modalities).toEqual({ input: ["text", "image"] }); + const model = buildEngyModel( + { + id: "engy-unlisted-preview", + input_modalities: ["PDF", "video", "image", "text", "audio", "text", "telepathy"], + output_modalities: [], + }, + undefined, + ); + // An empty list reports nothing; with no authored list it degrades to text. + expect(model.modalities).toEqual({ input: ["text", "image", "audio", "video", "pdf"], output: ["text"] }); +}); + +test("keeps the authored modalities when the list omits them", () => { + // A missing list is "not reported", not text-only: a field rename upstream + // must not narrow every file on the hourly run. + const row = { + ...KIMI_K3.wire, + id: "engy-unlisted-preview", + input_modalities: undefined, + output_modalities: undefined, + }; + const authored: ExistingModel = { modalities: { input: ["text", "image"], output: ["text", "image"] } }; + expect(buildEngyModel(row, authored).modalities).toEqual(authored.modalities); + expect(buildEngyModel(row, undefined).modalities).toEqual({ input: ["text"], output: ["text"] }); +}); + +test("an empty or unrecognised list keeps the authored modalities, and file means pdf", () => { + const authored: ExistingModel = { modalities: { input: ["text", "image"], output: ["text"] } }; + for (const input_modalities of [[], ["telepathy"]]) { + const model = buildEngyModel({ id: "engy-unlisted-preview", input_modalities }, authored); + expect(model).toMatchObject({ attachment: true, modalities: { input: ["text", "image"] } }); + } + const pdf = buildEngyModel({ id: "engy-unlisted-preview", input_modalities: ["file", "text"] }, undefined); + expect(pdf.modalities?.input).toEqual(["text", "pdf"]); +}); + +test("derives attachment from the input engy serves", () => { + // Text-only on engy while alibaba/qwen3.6-35b-a3b takes images: written out. + const qwen = buildEngyModel(SHIPPED["qwen3.6-35b-a3b"]!.wire, undefined); + expect(qwen).toMatchObject({ attachment: false, modalities: { input: ["text"] } }); + // Image-capable on engy and on the base: nothing to write. + expect(buildEngyModel(FLASH_ROW, undefined)).not.toHaveProperty("attachment"); + // No base to inherit from: written either way. + const bare = buildEngyModel({ id: "engy-unlisted-preview", input_modalities: ["image", "text"] }, undefined); + expect(bare.attachment).toBe(true); +}); + +test("resolves engy's bare slugs to the lab file each shipped entry points at", () => { + for (const [id, { resolved }] of Object.entries(SHIPPED)) { + expect(baseModelOf(buildEngyModel({ id }, undefined))).toBe(resolved.base_model); + } + expect(buildEngyModel({ id: "engy-unlisted-preview" }, undefined)).not.toHaveProperty("base_model"); +}); + +test("an authored base_model wins on a resolver miss", () => { + // The resolver has nothing for this slug, as it has nothing for an ambiguous + // one. The committed pointer must win, or the file is rewritten as an inline + // copy of the lab entry. + const model = buildEngyModel({ ...GLM52.wire, id: "engy-unlisted-preview" }, GLM52.resolved); + expect(baseModelOf(model)).toBe("zhipuai/glm-5.2"); + expect(model).not.toHaveProperty("description"); + expect(model).not.toHaveProperty("family"); + expect(model).toMatchObject({ cost: GLM52.resolved.cost, limit: GLM52.resolved.limit }); +}); + +test("carries every field the list does not report through an inline file", () => { + // Nothing factors these away on the inline path, so a field the module does + // not overwrite must survive the rewrite, including ones added to the schema later. + const existing: ExistingModel = { + ...GLM52_INLINE, + knowledge: "2026-03", + status: "beta", + type: "chat", + provider: { body: { chat_template_kwargs: { enable_thinking: true } } }, + experimental: { modes: { fast: { cost: { input: 1.36, output: 3 } } } }, + }; + const model = buildEngyModel({ ...GLM52.wire, id: "engy-unlisted-preview" }, existing); + expect(model).not.toHaveProperty("base_model"); + for (const key of [ + "name", "description", "family", "release_date", "last_updated", "reasoning", "temperature", "tool_call", + "structured_output", "open_weights", "knowledge", "status", "type", "interleaved", "provider", "experimental", + "reasoning_options", + ] as const) { + expect(existing[key]).toBeDefined(); + expect(model[key]).toEqual(existing[key]); + } +}); + +test("passes an authored base_model_omit through", () => { + const model = buildEngyModel(GLM52.wire, { ...GLM52.resolved, base_model_omit: ["limit.input"] }); + expect(model).toMatchObject({ base_model: "zhipuai/glm-5.2", base_model_omit: ["limit.input"] }); +}); + +test("treats an absent or zero context as not reported and never guesses the split", () => { + // `??` alone would write context = 0 under the authored input/output split + // from one bad row. + for (const context_length of [undefined, 0]) { + const model = buildEngyModel({ ...GLM52.wire, id: "engy-unlisted-preview", context_length }, GLM52_INLINE); + expect(model.limit).toEqual(GLM52.resolved.limit); + } + // The split lives behind auth on engy.ai/api/v1/models, and the advertised + // context is max_input + max_output, so an unseen split stays unset. + const bare = buildEngyModel({ ...GLM52.wire, id: "engy-unlisted-preview" }, undefined); + expect(bare.limit).toEqual({ context: GLM52.wire.context_length, input: undefined, output: 0 }); +}); + +test("a changed context leaves the split to a human instead of writing a file that contradicts itself", () => { + // engy's context is max_input + max_output, and the public list cannot say which half moved. + const row = { ...KIMI_K3.wire, context_length: 1_179_648 }; + expect(() => buildEngyModel(row, KIMI_K3.resolved)).toThrow(MissingReasoningOptionsError); + expect(() => buildEngyModel(row, KIMI_K3.resolved)).toThrow("re-read max_input_tokens"); + // Without an authored split there is nothing to contradict. + const { input: _input, ...noSplit } = KIMI_K3.resolved.limit!; + expect(buildEngyModel(row, { ...KIMI_K3.resolved, limit: noSplit }).limit?.context).toBe(1_179_648); +}); + +test("rejects an empty or non-numeric price instead of writing a free model", () => { + for (const prompt of ["", " ", "abc", "-0.000001"]) { + const raw = { data: [{ ...GLM52.wire, pricing: { ...GLM52.wire.pricing, prompt } }] }; + expect(() => engy.parseModels(raw)).toThrow(); + } + expect(engy.parseModels({ data: [{ ...GLM52.wire, pricing: { ...GLM52.wire.pricing, prompt: "0" } }] })).toHaveLength(1); + expect(() => engy.parseModels({ data: [{ ...GLM52.wire, context_length: 262_144.5 }] })).toThrow(); +}); + +test("ignores a row with an empty id", () => { + // engy served one on 2026-10-09: no price, context 208,192. + const raw = { data: [{ id: "", context_length: 208_192, input_modalities: ["text"] }, GLM52.wire] }; + expect(engy.parseModels(raw).map((model) => model.id)).toEqual(["glm-5.2"]); +}); + +test("fetches the public list without auth and fails on an error status", async () => { + const calls: unknown[][] = []; + const ok = (async (...args: unknown[]) => { + calls.push(args); + return new Response(JSON.stringify(PUBLIC_LIST)); + }) as unknown as typeof fetch; + expect(await fetchEngyModels(ok)).toEqual(PUBLIC_LIST); + expect(calls).toEqual([["https://api.engy.ai/v1/models"]]); + const down = (async () => new Response("", { status: 503, statusText: "Service Unavailable" })) as unknown as typeof fetch; + await expect(fetchEngyModels(down)).rejects.toThrow("engy models request failed: 503 Service Unavailable"); +}); + +test("is registered in the direct group", () => { + expect(providers.engy).toBe(engy); + expect(groups.direct).toContain("engy"); +}); + +test("falls back to the authored cost when the list quotes no prices", () => { + const model = buildEngyModel({ ...GLM52.wire, pricing: undefined }, GLM52.resolved); + expect(model.cost).toEqual(GLM52.resolved.cost); +}); + +test("keeps the authored cache_read when the list quotes none", () => { + const model = buildEngyModel( + { ...GLM52.wire, pricing: { prompt: "0.0000007", completion: GLM52.wire.pricing?.completion } }, + GLM52.resolved, + ); + expect(model.cost).toEqual({ ...GLM52.resolved.cost, input: 0.7 }); +}); + +test("a repricing keeps the authored cost fields the list never quotes", () => { + const cost: ExistingModel["cost"] = { + ...GLM52.resolved.cost!, + cache_write: 0.85, + reasoning: 1.5, + input_audio: 2, + output_audio: 4, + tiers: [{ tier: { type: "context", size: 200_000 }, input: 1.36, output: 3, cache_read: 0.36 }], + }; + const model = buildEngyModel(wireRow("glm-5.2", GLM52.resolved, "0.0000007"), { ...GLM52.resolved, cost }); + expect(model.cost).toEqual({ ...cost, input: 0.7 }); +}); + +// A throwaway checkout: the engy files plus the lab files they point at, so the +// runner resolves base_model exactly as it does in the repo. +async function inRoot(files: Record, run: (modelsDir: string) => Promise) { + const root = await mkdtemp(path.join(tmpdir(), "models-dev-engy-")); + const modelsDir = path.join(root, "providers", "engy", "models"); + await mkdir(modelsDir, { recursive: true }); + try { + for (const [file, text] of Object.entries(files)) { + await writeFile(path.join(modelsDir, file), text); + const base = (Bun.TOML.parse(text) as ExistingModel).base_model; + if (base === undefined) continue; + await mkdir(path.join(root, "models", path.dirname(base)), { recursive: true }); + await copyFile(path.join(LAB_DIR, `${base}.toml`), path.join(root, "models", `${base}.toml`)); + } + await run(modelsDir); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function engyAt(modelsDir: string, source: () => unknown): SyncProvider { + return { ...engy, modelsDir, async fetchModels() { return source(); } }; +} + +// Every shipped model as engy would list it, with any prompt price overridden. +function list(prompt: Record = {}) { + return { + object: "list", + data: Object.entries(SHIPPED).map(([id, { resolved }]) => wireRow(id, resolved, prompt[id])), + }; +} + +function read(modelsDir: string, file: string) { + return readFile(path.join(modelsDir, file), "utf8"); +} + +// Doubling a price is exact in binary, so the rewritten line is predictable. +function doubled(shipped: Shipped) { + const input = shipped.resolved.cost!.input; + return { + prompt: perToken(input * 2)!, + rewrite: (text: string) => text.replace(`input = ${input}\n`, `input = ${input * 2}\n`), + }; +} + +test("the shipped files are a fixed point, and unauthored models are only reported", async () => { + await inRoot({ "glm-5.2.toml": GLM52.text, "kimi-k3.toml": KIMI_K3.text }, async (modelsDir) => { + const provider = engyAt(modelsDir, () => list()); + const first = await syncProvider(provider); + const second = await syncProvider(provider); + for (const result of [first, second]) { + expect(result).toMatchObject({ created: 0, updated: 0, deleted: 0, unchanged: 2, files: [] }); + } + expect(await read(modelsDir, "glm-5.2.toml")).toBe(GLM52.text); + expect(await read(modelsDir, "kimi-k3.toml")).toBe(KIMI_K3.text); + + // skipCreates: a created file would ship limit.output = 0, so the models + // with no local file are named instead. + expect(first.notices).toEqual([expect.stringContaining("not created")]); + for (const id of Object.keys(SHIPPED).filter((id) => id !== "glm-5.2" && id !== "kimi-k3")) { + expect(await Bun.file(path.join(modelsDir, `${id}.toml`)).exists()).toBe(false); + expect(first.notices[0]).toContain(`\`${id}\``); + } + expect(first.notices[0]).not.toContain("`kimi-k3`"); + }); +}); + +test("a repricing changes the price line and nothing else", async () => { + // Shipped kimi-k3, plus a glm-5.2 that overrides two intrinsic fields of its + // base. An exact match proves the header, reasoning_options, the authored + // limit split, the overrides and the modality order all survive the rewrite. + const glm52 = GLM52.text.replace("\n\n[interleaved]", "\ntemperature = false\ntool_call = false\n\n[interleaved]"); + await inRoot({ "glm-5.2.toml": glm52, "kimi-k3.toml": KIMI_K3.text }, async (modelsDir) => { + let catalog = list(); + const provider = engyAt(modelsDir, () => catalog); + expect(await syncProvider(provider)).toMatchObject({ updated: 0, unchanged: 2 }); + + const glm = doubled(GLM52); + const kimi = doubled(KIMI_K3); + catalog = list({ "glm-5.2": glm.prompt, "kimi-k3": kimi.prompt }); + expect(await syncProvider(provider)).toMatchObject({ created: 0, updated: 2, deleted: 0 }); + expect(await read(modelsDir, "glm-5.2.toml")).toBe(glm.rewrite(glm52)); + expect(await read(modelsDir, "kimi-k3.toml")).toBe(kimi.rewrite(KIMI_K3.text)); + }); +}); + +test("a committed file stays factored when its slug no longer resolves", async () => { + // The same shape as an ambiguous slug: the resolver has nothing, but the file + // already points at zhipuai/glm-5.2. + await inRoot({ "engy-unlisted-preview.toml": GLM52.text }, async (modelsDir) => { + const row = (prompt?: string) => ({ + object: "list", + data: [{ ...wireRow("glm-5.2", GLM52.resolved, prompt), id: "engy-unlisted-preview" }], + }); + let catalog = row(); + const provider = engyAt(modelsDir, () => catalog); + expect(await syncProvider(provider)).toMatchObject({ updated: 0, unchanged: 1 }); + + const glm = doubled(GLM52); + catalog = row(glm.prompt); + expect(await syncProvider(provider)).toMatchObject({ updated: 1 }); + expect(await read(modelsDir, "engy-unlisted-preview.toml")).toBe(glm.rewrite(GLM52.text)); + }); +}); + +test("an empty list deletes nothing and names the retained files", async () => { + // {"data":[]} is a valid response from the unauthenticated endpoint, and + // skipCreates could not bring a deleted file back. + await inRoot({ "glm-5.2.toml": GLM52.text, "kimi-k3.toml": KIMI_K3.text }, async (modelsDir) => { + const result = await syncProvider(engyAt(modelsDir, () => ({ object: "list", data: [] }))); + expect(result).toMatchObject({ created: 0, updated: 0, deleted: 0, unchanged: 2, files: [] }); + expect(await read(modelsDir, "glm-5.2.toml")).toBe(GLM52.text); + expect(result.notices).toEqual([expect.stringContaining("retained")]); + expect(result.notices[0]).toContain("`glm-5.2.toml`"); + expect(result.notices[0]).toContain("`kimi-k3.toml`"); + }); +}); + +test("a changed context leaves the file untouched and names it for a human", async () => { + await inRoot({ "glm-5.2.toml": GLM52.text, "kimi-k3.toml": KIMI_K3.text }, async (modelsDir) => { + const catalog = list(); + catalog.data = catalog.data.map((row) => row.id === "kimi-k3" ? { ...row, context_length: 1_179_648 } : row); + const result = await syncProvider(engyAt(modelsDir, () => catalog)); + expect(result).toMatchObject({ created: 0, updated: 0, deleted: 0 }); + expect(await read(modelsDir, "kimi-k3.toml")).toBe(KIMI_K3.text); + expect(result.notices).toContainEqual(expect.stringContaining("kimi-k3: engy's context_length is now 1179648")); + }); +}); diff --git a/sync.md b/sync.md index 29859c5e83a..ddc7ddb74b3 100644 --- a/sync.md +++ b/sync.md @@ -379,6 +379,22 @@ Venice is implemented in `packages/core/src/sync/providers/venice.ts`. - Every Venice model uses `base_model`; flattened IDs are matched to provider-agnostic metadata before provider-specific overrides are written. - Every Venice model declares `reasoning_options`; models without API-provided effort levels use an empty array. +## engy Notes + +engy is implemented in `packages/core/src/sync/providers/engy.ts`. + +- Run it with `bun models:sync engy` or `bun engy:sync`. +- Source endpoint: `https://api.engy.ai/v1/models`; no auth required. +- The endpoint owns `cost.input`, `cost.output` and `cost.cache_read` while quoted, `limit.context` when positive, and `modalities` when it sends a recognised list (`file` maps to `pdf`). +- Every other authored field is preserved, including `limit.input`/`limit.output`, `reasoning_options`, `interleaved` and the other cost keys; no public endpoint reports them. +- `context_length` is `max_input + max_output`. When it no longer matches the authored split, the file is left alone and the model gets a `[missing-model]` issue to re-read the split from the authenticated `https://engy.ai/api/v1/models`. +- A price that is not a non-negative number fails the run; a row with an empty `id` is ignored. +- `skipCreates` and `trackMissingModels` are set: a created file would ship `limit.output = 0`. Unseen IDs open deduped `[missing-model]` issues. +- `deleteMissing` is `false`: the list is unauthenticated and `{"data":[]}` passes the schema, so a truncated 200 must not delete hand-measured files; they are retained and reported. +- An authored `base_model` wins over the resolver, so a miss never de-factors a committed file. +- Per-token USD string prices become per-1M, rounded to six decimals; two would carry float error otherwise. +- Modalities are sorted into catalogue order; wire order is arbitrary. + ## Standalone Generators Some provider scripts in `packages/core/script/generate-*.ts` are not wired into `bun models:sync`. When updating those scripts, preserve existing `base_model` and `base_model_omit` fields for generated TOMLs that already use model metadata inheritance. New inheritance-aware output should use `base_model`; do not reintroduce legacy `[extends]` syntax.