diff --git a/models/jaredpalmer/kev-4b.toml b/models/jaredpalmer/kev-4b.toml new file mode 100644 index 00000000000..ceb8e062db8 --- /dev/null +++ b/models/jaredpalmer/kev-4b.toml @@ -0,0 +1,41 @@ +# Source: https://huggingface.co/jaredpalmer/kev-4b (model card, accessed 2026-10-20). +# Kev 1.0, first published 2026-09-24: a LoRA adapter and pointer head on Qwen3.5-4B-Base that +# returns a probability per option and generates no text, so output is 0. States of up to +# 65,536 tokens are served; the card validates accuracy to 8,192. English only. +schema = 2 +v2_only = true +type = "decision" +name = "Kev 4B" +description = "Jared Palmer's open-weight 4B decision model that returns calibrated probabilities for typed choice, score and yes/no questions about a text or JSON state" +open_weights = true +release_date = "2026-09-24" +license = "Apache-2.0" + +[modalities] +input = ["text"] +output = ["decision"] + +[capabilities] +tools = { supported = false } +reasoning = { supported = false } +temperature = false +top_p = false +top_k = false + +[limit] +context = 65_536 +output = 0 + +[[links]] +label = "Model card" +url = "https://huggingface.co/jaredpalmer/kev-4b" +type = "model_card" + +[[links]] +label = "Source code" +url = "https://github.com/jaredpalmer/kev" +type = "other" + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/jaredpalmer/kev-4b" diff --git a/models/vllm-sr/d3-mini.toml b/models/vllm-sr/d3-mini.toml new file mode 100644 index 00000000000..cdfd69f3691 --- /dev/null +++ b/models/vllm-sr/d3-mini.toml @@ -0,0 +1,34 @@ +# Source: https://huggingface.co/vllm-sr/d3-mini (model card, config.json, decision_config.json; accessed +# 2026-10-21). Decision 3.0 from vLLM Semantic Router: a fine-tune of Qwen3.5-4B (4.54B parameters with a +# 0.33B vision encoder) that returns a probability per option and generates no text, so output is 0. +# Context: decision_config.json sets max_length = null, so the vLLM-SR runtime serves max_position_embeddings, +# 262,144 (vllm-project/semantic-router src/model-runtime/vllm_srun/families/decision3/package.py). +# Tag v3.0.0 was pushed on 2026-10-10 and the current weights, v3.1.0, on 2026-10-11. +schema = 2 +v2_only = true +type = "decision" +name = "d3-mini" +description = "vLLM Semantic Router 4B decision model that answers typed choice, yes/no and score questions about text, images or video with probabilities" +open_weights = true +release_date = "2026-10-10" +last_updated = "2026-10-11" +license = "Apache-2.0" + +[modalities] +input = ["text", "image", "video"] +output = ["decision"] + +[capabilities] +tools = { supported = false } +reasoning = { supported = false } +temperature = false +top_p = false +top_k = false + +[limit] +context = 262_144 +output = 0 + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/vllm-sr/d3-mini" diff --git a/package.json b/package.json index 01e6d2d08e9..f8e2eeb1c52 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "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", + "verathos:sync": "bun ./packages/core/script/sync-models.ts verathos", "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/generate.ts b/packages/core/src/generate.ts index f59f6f6efba..5c60b261d47 100755 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -135,6 +135,7 @@ async function generateProviders( cause: { providerPath }, }); } + let v2OnlyCount = 0; for await (const modelPath of new Bun.Glob("**/*.toml").scan({ cwd: modelsPath, absolute: true, @@ -148,7 +149,10 @@ async function generateProviders( }).then((mod) => mod.default); raw.id = modelID; // Models marked `v2_only = true` are only published in the v2 API. - if (raw.v2_only === true) continue; + if (raw.v2_only === true) { + v2OnlyCount++; + continue; + } const toml = modelV2ToV1(raw, { modelPath }, provider.data.npm); if (toml.base_model !== undefined) { const baseModel = BaseModel.safeParse(toml); @@ -177,6 +181,8 @@ async function generateProviders( provider.data.models[modelID] = normalizeModelCost(model.data); } if (Object.keys(provider.data.models).length === 0) { + // A provider that serves only v2_only models is left out of v1. + if (v2OnlyCount > 0) continue; throw new Error(`Provider "${providerID}" has no models`, { cause: { providerPath }, }); diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 2aa25ea8c45..b8a17b7fd26 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -60,6 +60,7 @@ import { requesty } from "./providers/requesty.js"; import { tinfoil } from "./providers/tinfoil.js"; import { vercel } from "./providers/vercel.js"; import { venice } from "./providers/venice.js"; +import { verathos } from "./providers/verathos.js"; import { wandb } from "./providers/wandb.js"; import { xai } from "./providers/xai.js"; @@ -224,6 +225,7 @@ export const providers: { tinfoil: SyncProvider; vercel: SyncProvider; venice: SyncProvider; + verathos: SyncProvider; wandb: SyncProvider; xai: SyncProvider; } = { @@ -269,6 +271,7 @@ export const providers: { tinfoil, vercel, venice, + verathos, wandb, xai, }; @@ -294,7 +297,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "engy", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "mistral", "nearai", "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", "nearai", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "verathos", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/verathos.ts b/packages/core/src/sync/providers/verathos.ts new file mode 100644 index 00000000000..64f4f338d61 --- /dev/null +++ b/packages/core/src/sync/providers/verathos.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; + +import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; + +const API_ENDPOINT = "https://api.verathos.ai/v1/models"; + +// USD per 1M tokens. A missing or negative price must fail the run, not be +// written as a free model. +const Price = z + .object({ + usd_per_million_input_tokens: z.number().nonnegative(), + usd_per_million_output_tokens: z.number().nonnegative(), + }) + .passthrough(); + +export const VerathosModel = z + .object({ + id: z.string(), + max_state_tokens: z.number().int().optional(), + input_modalities: z.array(z.string()).nullish(), + price: Price.optional(), + available: z.boolean().optional(), + }) + .passthrough(); + +export const VerathosResponse = z.object({ data: z.array(VerathosModel) }).passthrough(); + +export type VerathosModel = z.infer; + +type Modality = "text" | "audio" | "image" | "video" | "pdf"; + +export const verathos = { + id: "verathos", + name: "Verathos", + modelsDir: "providers/verathos/models", + // The list has no name, description or lab facts, and every model needs a + // decision-type lab entry first. 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 authored files. + deleteMissing: false, + sourceID(model) { + return model.id; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} Verathos models have no local catalog file and were not created because the list carries no lab metadata; author a decision-type lab entry and a provider file: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + missingNotice(paths) { + if (paths.length === 0) return []; + return [ + `${paths.length} local Verathos models are missing from the public list and were retained; a human removes retired models: ${paths.map((path) => `\`${path}\``).join(", ")}`, + ]; + }, + async fetchModels() { + return fetchVerathosModels(); + }, + parseModels(raw) { + return parseVerathosModels(raw); + }, + translateModel(model, context) { + return { + id: model.id, + model: buildVerathosModel(model, context.existing(model.id), context.authored(model.id)), + }; + }, +} satisfies SyncProvider; + +export async function fetchVerathosModels(fetcher: typeof fetch = fetch) { + const response = await fetcher(API_ENDPOINT); + if (!response.ok) { + throw new Error(`Verathos models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export function parseVerathosModels(raw: unknown): VerathosModel[] { + const rows = VerathosResponse.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(`verathos: ignored ${unnamed} model row(s) with an empty id`); + return rows.filter((model) => model.id.trim() !== ""); +} + +export function buildVerathosModel( + model: VerathosModel, + existing: ExistingModel | undefined, + authored?: ExistingModel, +): 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 = existing?.modalities?.output ?? ["text"]; + + // max_state_tokens is the largest state Verathos accepts (413 above it). A + // non-positive value is a bad row, not a smaller window. + const apiContext = model.max_state_tokens ?? 0; + const limit = { + context: apiContext > 0 ? apiContext : existing?.limit?.context ?? 0, + input: existing?.limit?.input, + // Decision models generate no text. + output: existing?.limit?.output ?? 0, + }; + + const cost = + model.price === undefined + ? existing?.cost + : { + ...existing?.cost, + input: round(model.price.usd_per_million_input_tokens), + output: round(model.price.usd_per_million_output_tokens), + }; + + // 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 must not de-factor a committed file. + const baseModel = authoredBase ?? resolveModelMetadataBaseModel(model.id); + if (baseModel === undefined) return values as SyncedModel; + const factored = factorBaseModel(baseModel, values, limit, baseModelOmit); + // A v2 file's modalities (MIME types, output "decision") read as plain v1 values + // equal to the lab's, so factoring drops them and the runner would delete the + // table. While the list agrees with the authored values, keep them. + if (authored?.modalities !== undefined && sameJSON(authored.modalities, values.modalities)) { + return { ...factored, attachment: authored.attachment, modalities: authored.modalities } as SyncedModel; + } + return factored; +} + +function sameJSON(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +// Prices are already per-1M; round to micro-dollars so a float in the JSON +// never rewrites a file. +function round(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + +function normalizeModalities(values: string[] | null | undefined): Modality[] | undefined { + if (values === undefined || values === null) return undefined; + const allowed = new Set(["text", "audio", "image", "video", "pdf"]); + const result = values + .map((value) => value.toLowerCase()) + .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/generate.test.ts b/packages/core/test/generate.test.ts index 02f1a17b09d..e7fb828be1b 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -45,6 +45,51 @@ describe("catalog generation", () => { }); }); + test("leaves providers with only v2_only models out of v1", async () => { + await withFixture(async (root) => { + await write(root, "providers/decider/provider.toml", providerToml("Decider")); + await write( + root, + "providers/decider/models/decide.toml", + `schema = 2 +v2_only = true +type = "decision" +name = "Decide" +description = "Example decision model that only the v2 catalog publishes" +open_weights = true +release_date = "2026-01-02" + +[modalities] +input = ["text"] +output = ["decision"] + +[capabilities] +tools = { supported = false } +reasoning = { supported = false } +temperature = false +top_p = false +top_k = false + +[limit] +context = 8_192 +output = 0 + +[cost.text.input] +price = 1 + +[cost.text.output] +price = 0 +`, + ); + + const providers = await generate(path.join(root, "providers")); + expect(providers.decider).toBeUndefined(); + + const providersV2 = await generateV2(path.join(root, "providers")); + expect(Object.keys(providersV2.decider?.models ?? {})).toEqual(["decide"]); + }); + }); + test("base_model publishes canonical identity with the provider model", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); diff --git a/packages/core/test/verathos.test.ts b/packages/core/test/verathos.test.ts new file mode 100644 index 00000000000..00d44d21a10 --- /dev/null +++ b/packages/core/test/verathos.test.ts @@ -0,0 +1,217 @@ +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 { groups, providers, syncProvider, type ExistingModel, type SyncProvider } from "../src/sync/index.js"; +import { buildVerathosModel, fetchVerathosModels, verathos, type VerathosModel } from "../src/sync/providers/verathos.js"; +import { modelMetadata } from "../src/sync/providers/openrouter.js"; +import { modelV2ToV1 } from "../src/translate.js"; + +const REPO = path.join(import.meta.dirname, "..", "..", ".."); +const SHIPPED_DIR = path.join(REPO, "providers", "verathos", "models"); +const LAB_DIR = path.join(REPO, "models"); + +// GET https://api.verathos.ai/v1/models (2026-10-20), trimmed to the fields the +// sync reads plus one it ignores. Prices are USD per 1M tokens. +const row = (id: string, max_state_tokens: number, input_modalities: string[], input: number): VerathosModel => ({ + id, + object: "model", + max_state_tokens, + input_modalities, + price: { usd_per_million_input_tokens: input, usd_per_million_output_tokens: 0 }, + available: true, + latency_ms: { p50: 449, p95: 564, window_s: 72000 }, +}); +const LIVE = () => ({ + object: "list", + data: [ + row("kev-4b", 65_536, ["text"], 0.015), + row("clef-flash", 65_536, ["text", "image"], 0.02), + row("d3-flash", 16_384, ["text"], 0.02), + row("d3-mini", 16_384, ["text"], 0.015), + ], +}); + +const SHIPPED: Record = Object.fromEntries( + readdirSync(SHIPPED_DIR).map((file) => [file, readFileSync(path.join(SHIPPED_DIR, file), "utf8")]), +); + +// A shipped v2 file read as v1: what the runner hands the module as `authored`. +function authoredV1(file: string): ExistingModel { + const raw = Bun.TOML.parse(SHIPPED[file]!) as Record; + return modelV2ToV1({ ...raw, id: file.slice(0, -5) }, { modelPath: file }) as ExistingModel; +} + +// That file merged over its lab entry: what the runner hands the module as `existing`. +function resolved(file: string): ExistingModel { + const authored = authoredV1(file); + const { benchmarks: _benchmarks, weights: _weights, ...base } = modelMetadata(authored.base_model!); + return mergeDeep(base, authored) as ExistingModel; +} + +// A throwaway checkout: the Verathos 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-verathos-")); + const modelsDir = path.join(root, "providers", "verathos", "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!; + 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 at(modelsDir: string, source: () => unknown): SyncProvider { + return { ...verathos, modelsDir, async fetchModels() { return source(); } }; +} + +const read = (modelsDir: string, file: string) => readFile(path.join(modelsDir, file), "utf8"); + +test("is update-only and registered in the direct group", () => { + expect(verathos).toMatchObject({ skipCreates: true, trackMissingModels: true, deleteMissing: false }); + expect(providers.verathos).toBe(verathos); + expect(groups.direct).toContain("verathos"); +}); + +test("the shipped files are a fixed point against the live list", async () => { + await inRoot(SHIPPED, async (modelsDir) => { + const provider = at(modelsDir, LIVE); + for (let run = 0; run < 2; run++) { + expect(await syncProvider(provider)).toMatchObject({ created: 0, updated: 0, deleted: 0, unchanged: 4, files: [] }); + } + for (const [file, text] of Object.entries(SHIPPED)) expect(await read(modelsDir, file)).toBe(text); + }); +}); + +test("a repricing or a new cap changes that line and keeps the header and overrides", async () => { + await inRoot(SHIPPED, async (modelsDir) => { + const catalog = LIVE(); + catalog.data[1] = row("clef-flash", 32_768, ["text", "image"], 0.04); + catalog.data[2] = row("d3-flash", 24_576, ["text"], 0.02); + expect(await syncProvider(at(modelsDir, () => catalog))).toMatchObject({ created: 0, updated: 2, deleted: 0 }); + expect(await read(modelsDir, "clef-flash.toml")).toBe( + SHIPPED["clef-flash.toml"]!.replace("price = 0.02\n", "price = 0.04\n").replace("context = 65_536", "context = 32_768"), + ); + expect(await read(modelsDir, "d3-flash.toml")).toBe(SHIPPED["d3-flash.toml"]!.replace("context = 16_384", "context = 24_576")); + }); +}); + +test("D3 starting to read images widens its input and keeps the decision output", async () => { + await inRoot({ "d3-mini.toml": SHIPPED["d3-mini.toml"]! }, async (modelsDir) => { + const catalog = { object: "list", data: [row("d3-mini", 16_384, ["image", "text"], 0.015)] }; + expect(await syncProvider(at(modelsDir, () => catalog))).toMatchObject({ updated: 1 }); + const model = Bun.TOML.parse(await read(modelsDir, "d3-mini.toml")) as ExistingModel; + // Input now matches the lab entry except video, so it stays an override. + expect(model.modalities?.input).toEqual(["text", "image"]); + expect(model.modalities?.output).toEqual(["decision"]); + }); +}); + +test("an unseen model is only reported, and an empty list deletes nothing", async () => { + await inRoot({ "kev-4b.toml": SHIPPED["kev-4b.toml"]! }, async (modelsDir) => { + const catalog = { object: "list", data: [row("kev-4b", 65_536, ["text"], 0.015), row("kev-5b", 65_536, ["text"], 0.02)] }; + const seen = await syncProvider(at(modelsDir, () => catalog)); + expect(seen).toMatchObject({ created: 0, updated: 0, unchanged: 1 }); + expect(seen.notices).toEqual([expect.stringContaining("`kev-5b`")]); + expect(await Bun.file(path.join(modelsDir, "kev-5b.toml")).exists()).toBe(false); + + const empty = await syncProvider(at(modelsDir, () => ({ object: "list", data: [] }))); + expect(empty).toMatchObject({ deleted: 0, unchanged: 1 }); + expect(empty.notices).toEqual([expect.stringContaining("`kev-4b.toml`")]); + }); +}); + +test("a missing or negative price fails the run instead of writing a free model", () => { + const priced = (price: unknown) => ({ object: "list", data: [{ ...row("kev-4b", 65_536, ["text"], 0.015), price }] }); + expect(() => verathos.parseModels(priced({ usd_per_million_input_tokens: -1, usd_per_million_output_tokens: 0 }))).toThrow(); + expect(() => verathos.parseModels(priced({ usd_per_million_output_tokens: 0 }))).toThrow(); +}); + +test("fetches the public list without auth and fails on an error status", async () => { + let init: RequestInit | undefined; + const ok = (async (_url: string, options?: RequestInit) => { + init = options; + return new Response(JSON.stringify(LIVE())); + }) as unknown as typeof fetch; + expect(await fetchVerathosModels(ok)).toEqual(LIVE()); + expect(init).toBeUndefined(); + const failing = (async () => new Response("", { status: 503, statusText: "Service Unavailable" })) as unknown as typeof fetch; + await expect(fetchVerathosModels(failing)).rejects.toThrow("503"); +}); + +test("ignores a row with an empty id", () => { + const catalog = { object: "list", data: [row("", 65_536, ["text"], 0.015), row(" ", 65_536, ["text"], 0.015), row("kev-4b", 65_536, ["text"], 0.015)] }; + expect(verathos.parseModels(catalog).map((model) => model.id)).toEqual(["kev-4b"]); +}); + +test("an absent, empty or unrecognised modality list keeps the authored input", () => { + // clef-flash reads images, so a fallback to text-only would show. + // An authored table that reads in v1 like the lab's, as MIME types and "decision" do. + const authored = { ...authoredV1("clef-flash.toml"), attachment: true, modalities: { input: ["text", "image"], output: ["text"] } } as ExistingModel; + const existing = { ...resolved("clef-flash.toml"), ...authored }; + for (const input_modalities of [undefined, null, [], ["telepathy"]]) { + const model = buildVerathosModel({ ...row("clef-flash", 65_536, [], 0.02), input_modalities }, existing, authored); + expect(model.modalities).toEqual(authored.modalities); + expect(model.attachment).toBe(true); + } +}); + +test("a repricing keeps clef-flash's inherited modalities", async () => { + await inRoot({ "clef-flash.toml": SHIPPED["clef-flash.toml"]! }, async (modelsDir) => { + const catalog = { object: "list", data: [row("clef-flash", 65_536, ["image", "text"], 0.03)] }; + expect(await syncProvider(at(modelsDir, () => catalog))).toMatchObject({ updated: 1 }); + expect(await read(modelsDir, "clef-flash.toml")).toBe(SHIPPED["clef-flash.toml"]!.replace("price = 0.02\n", "price = 0.03\n")); + }); +}); + +test("falls back to the authored cost when the list quotes no price", () => { + const { price: _price, ...unpriced } = row("kev-4b", 65_536, ["text"], 0.015); + expect(buildVerathosModel(unpriced, { ...resolved("kev-4b.toml"), cost: { input: 0.03, output: 0 } }).cost).toEqual({ input: 0.03, output: 0 }); +}); + +test("passes an authored base_model_omit through", () => { + const existing = { ...resolved("kev-4b.toml"), base_model_omit: ["limit.input"] }; + expect(buildVerathosModel(row("kev-4b", 65_536, ["text"], 0.015), existing)).toMatchObject({ + base_model: "jaredpalmer/kev-4b", + base_model_omit: ["limit.input"], + }); +}); + +test("a repricing keeps the authored cost keys the list never quotes", () => { + const existing = { ...resolved("kev-4b.toml"), cost: { input: 0.015, output: 0, cache_read: 0.005 } }; + expect(buildVerathosModel(row("kev-4b", 65_536, ["text"], 0.03), existing).cost).toEqual({ input: 0.03, output: 0, cache_read: 0.005 }); +}); + +test("rounds a float price to micro-dollars so the hourly run is a no-op", () => { + expect(0.1 + 0.2).toBe(0.30000000000000004); + expect(buildVerathosModel(row("kev-4b", 65_536, ["text"], 0.1 + 0.2), resolved("kev-4b.toml")).cost?.input).toBe(0.3); +}); + +test("treats an absent or zero max_state_tokens as not reported", () => { + const existing = resolved("d3-flash.toml"); + for (const max_state_tokens of [undefined, 0, -1]) { + expect(buildVerathosModel({ ...row("d3-flash", 0, ["text"], 0.02), max_state_tokens }, existing).limit).toEqual({ context: 16_384 }); + } +}); + +test("keeps an authored limit.output", () => { + const existing = resolved("kev-4b.toml"); + const model = buildVerathosModel(row("kev-4b", 65_536, ["text"], 0.015), { ...existing, limit: { ...existing.limit!, output: 1 } }); + expect(model.limit).toEqual({ output: 1 }); +}); + +test("an authored base_model wins over the slug resolver", () => { + // clef-flash resolves to cloudflare/clef-flash; a committed pointer elsewhere must stand. + const model = buildVerathosModel(row("clef-flash", 65_536, ["text"], 0.015), resolved("kev-4b.toml")); + expect(model).toMatchObject({ base_model: "jaredpalmer/kev-4b" }); +}); diff --git a/providers/verathos/logo.svg b/providers/verathos/logo.svg new file mode 100644 index 00000000000..fd8d0577b6b --- /dev/null +++ b/providers/verathos/logo.svg @@ -0,0 +1 @@ + diff --git a/providers/verathos/models/clef-flash.toml b/providers/verathos/models/clef-flash.toml new file mode 100644 index 00000000000..f08e5f3878a --- /dev/null +++ b/providers/verathos/models/clef-flash.toml @@ -0,0 +1,22 @@ +# Price and limits: https://api.verathos.ai/v1/models (2026-10-20). Billed on input tokens only, so +# output is 0. Serving: POST /v1/systemone, which caps the state at 65,536 tokens (max_state_tokens) +# and takes up to 4 PNG, JPEG or WebP images. System One has no temperature parameter. +schema = 2 +v2_only = true +base_model = "cloudflare/clef-flash" + +[capabilities] +temperature = false + +[limit] +context = 65_536 + +[api.systemone] +base_url = "https://api.verathos.ai/v1" +sdk = { aisdk = "@ai-sdk/typesafe-ai", opencodeai = "@opencode/ai/providers/typesafe-ai" } + +[cost.text.input] +price = 0.02 + +[cost.text.output] +price = 0 diff --git a/providers/verathos/models/d3-flash.toml b/providers/verathos/models/d3-flash.toml new file mode 100644 index 00000000000..831f4125838 --- /dev/null +++ b/providers/verathos/models/d3-flash.toml @@ -0,0 +1,23 @@ +# Price and limits: https://api.verathos.ai/v1/models (2026-10-20). Billed on input tokens only, so +# output is 0. Serving: POST /v1/systemone with max_state_tokens 16,384; input_modalities is text +# only and a request with images gets 400, so this entry is text only. +schema = 2 +v2_only = true +base_model = "vllm-sr/d3-flash" + +[modalities] +input = ["text"] +output = ["decision"] + +[limit] +context = 16_384 + +[api.systemone] +base_url = "https://api.verathos.ai/v1" +sdk = { aisdk = "@ai-sdk/typesafe-ai", opencodeai = "@opencode/ai/providers/typesafe-ai" } + +[cost.text.input] +price = 0.02 + +[cost.text.output] +price = 0 diff --git a/providers/verathos/models/d3-mini.toml b/providers/verathos/models/d3-mini.toml new file mode 100644 index 00000000000..ecb193f6366 --- /dev/null +++ b/providers/verathos/models/d3-mini.toml @@ -0,0 +1,23 @@ +# Price and limits: https://api.verathos.ai/v1/models (2026-10-20). Billed on input tokens only, so +# output is 0. Serving: POST /v1/systemone with max_state_tokens 16,384; input_modalities is text +# only and a request with images gets 400, so this entry is text only. +schema = 2 +v2_only = true +base_model = "vllm-sr/d3-mini" + +[modalities] +input = ["text"] +output = ["decision"] + +[limit] +context = 16_384 + +[api.systemone] +base_url = "https://api.verathos.ai/v1" +sdk = { aisdk = "@ai-sdk/typesafe-ai", opencodeai = "@opencode/ai/providers/typesafe-ai" } + +[cost.text.input] +price = 0.015 + +[cost.text.output] +price = 0 diff --git a/providers/verathos/models/kev-4b.toml b/providers/verathos/models/kev-4b.toml new file mode 100644 index 00000000000..19a67753617 --- /dev/null +++ b/providers/verathos/models/kev-4b.toml @@ -0,0 +1,15 @@ +# Price: https://api.verathos.ai/v1/models (2026-10-20). Billed on input tokens only, so output is 0. +# Serving: POST /v1/systemone; max_state_tokens 65,536, text only, as the lab entry. +schema = 2 +v2_only = true +base_model = "jaredpalmer/kev-4b" + +[api.systemone] +base_url = "https://api.verathos.ai/v1" +sdk = { aisdk = "@ai-sdk/typesafe-ai", opencodeai = "@opencode/ai/providers/typesafe-ai" } + +[cost.text.input] +price = 0.015 + +[cost.text.output] +price = 0 diff --git a/providers/verathos/provider.toml b/providers/verathos/provider.toml new file mode 100644 index 00000000000..2546d758486 --- /dev/null +++ b/providers/verathos/provider.toml @@ -0,0 +1,13 @@ +# Verathos Decide serves open-weight decision models on Bittensor subnet 96. Requests go to +# POST /v1/systemone in the System One request and answer format (state, typed questions, +# a probability per option); there is no chat completions endpoint. `api` is required by the +# schema for `@ai-sdk/openai-compatible`. Keys are `Authorization: Bearer`, created at +# https://verathos.ai/account/keys. Prices, limits and image support per model are public at +# https://api.verathos.ai/v1/models; output tokens are free. https://verathos.ai/docs/api (accessed 2026-10-20) +# opencodeai = "none": there is no chat API; each model's [api.systemone] names its SDK. +name = "Verathos" +npm = "@ai-sdk/openai-compatible" +opencodeai = "none" +api = "https://api.verathos.ai/v1" +env = ["VERATHOS_API_KEY"] +doc = "https://verathos.ai/docs/models" diff --git a/sync.md b/sync.md index 34e6fd9ddb7..f034303e823 100644 --- a/sync.md +++ b/sync.md @@ -461,6 +461,19 @@ engy is implemented in `packages/core/src/sync/providers/engy.ts`. 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. +## Verathos Notes + +Verathos is implemented in `packages/core/src/sync/providers/verathos.ts`. + +- Run it with `bun models:sync verathos` or `bun verathos:sync`. +- Source endpoint: `https://api.verathos.ai/v1/models`; no auth required. Prices are already USD per 1M tokens. +- The endpoint owns `cost.input` and `cost.output`, `limit.context` (from `max_state_tokens`, the largest state `POST /v1/systemone` accepts) when positive, and `modalities.input` when it sends a recognised list. `attachment` follows the input list. +- Every other authored field is preserved, including `base_model`, `temperature` and `limit.output` (0: decision models generate no text). +- The files are v2 and `v2_only`. While the list agrees with a file's modalities, the authored table stays, even where its MIME types or `decision` output read like the lab's in v1. +- `skipCreates` and `trackMissingModels` are set: the list carries no name, description or lab facts, and each new model needs a `type = "decision"` lab entry first. Unseen IDs open deduped `[missing-model]` issues. +- `deleteMissing` is `false`: the list is unauthenticated and `{"data":[]}` passes the schema, so retired models are retained, reported and removed by hand. +- A price that is missing or negative fails the run; a row with an empty `id` is ignored. + ## ai& Notes - Endpoint: `GET https://api.aiand.com/v1/api.json` (public, no auth). The module reads the `aiand` provider entry; `AIAND_API_URL` overrides the endpoint for staging dry runs.