diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index 3ec03eb942d..c92af550d57 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -89,6 +89,7 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZENIFRA_AI_KEY: ${{ secrets.ZENIFRA_AI_KEY }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID }} CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID }} diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 031341b56cd..7796bd82816 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -43,6 +43,7 @@ import { vercel } from "./providers/vercel.js"; import { venice } from "./providers/venice.js"; import { wandb } from "./providers/wandb.js"; import { xai } from "./providers/xai.js"; +import { zenifra } from "./providers/zenifra.js"; const ExistingModelType = AuthoredModelShape.partial() .extend({ @@ -178,6 +179,7 @@ export const providers: { venice: SyncProvider; wandb: SyncProvider; xai: SyncProvider; + zenifra: SyncProvider; } = { aiand, ambient, @@ -217,6 +219,7 @@ export const providers: { venice, wandb, xai, + zenifra, }; export const groups = { @@ -237,7 +240,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", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai", "zenifra"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/zenifra.ts b/packages/core/src/sync/providers/zenifra.ts new file mode 100644 index 00000000000..be74a04c3d3 --- /dev/null +++ b/packages/core/src/sync/providers/zenifra.ts @@ -0,0 +1,358 @@ +import { z } from "zod"; + +import { ReasoningOption } from "../../schema.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; + +const API_ENDPOINT = "https://ai.zenifra.com/v1/models"; + +// Zenifra publishes its model prices in BRL per million tokens. Keep this in +// sync with the rate used by the authored catalog until the API publishes a +// currency field or the project adopts dynamic FX conversion. +const BRL_PER_USD = 5.2; + +const ZenifraPricingTier = z.object({ + min_input_tokens: z.number().int().nonnegative(), + max_input_tokens: z.number().int().positive().optional(), + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cache_read_input: z.number().nonnegative().optional(), +}).passthrough(); + +const ZenifraPricing = z.object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cache_read_input: z.number().nonnegative().optional(), + context_tiers: z.array(ZenifraPricingTier).optional(), +}).passthrough(); + +const ZenifraReasoning = z.object({ + supported: z.boolean(), + always_on: z.boolean().optional(), + effort_levels: z.array(z.string()).optional(), +}).passthrough(); + +const ZenifraCapabilities = z.object({ + response_schema: z.boolean().optional(), + structured_outputs: z.boolean().optional(), + function_calling: z.boolean().optional(), + tool_choice: z.boolean().optional(), + reasoning: ZenifraReasoning.optional(), +}).passthrough(); + +export const ZenifraModel = z.object({ + id: z.string().min(1), + object: z.literal("model"), + owned_by: z.string().min(1), + created: z.number().int().nonnegative(), + context_length: z.number().int().positive().optional(), + max_output_tokens: z.number().int().positive().optional(), + pricing: ZenifraPricing.optional(), + capabilities: ZenifraCapabilities.optional(), + input_modalities: z.array(z.string()).optional(), + output_modalities: z.array(z.string()).optional(), + supported_operations: z.array(z.string()).optional(), + supported_parameters: z.array(z.string()).optional(), +}).passthrough(); + +export const ZenifraResponse = z.object({ + object: z.literal("list"), + data: z.array(ZenifraModel), +}).passthrough(); + +export type ZenifraModel = z.infer; + +const CANONICAL_BASE_MODEL_OVERRIDES: Record = { + "zenifra/deepseek-v4-pro": "deepseek/deepseek-v4-pro-0813", +}; + +type Modality = "text" | "audio" | "image" | "video" | "pdf"; +type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default"; + +const REASONING_EFFORTS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "default", +]); + +export const zenifra = { + id: "zenifra", + name: "Zenifra", + modelsDir: "providers/zenifra/models", + // A valid but partial public feed must not erase the local catalog. Missing + // routes are retained for manual lifecycle review until Zenifra publishes a + // deletion-specific signal. + deleteMissing: false, + sourceID(model: ZenifraModel) { + return model.id; + }, + missingModelID(model: ZenifraModel) { + return model.id; + }, + skippedNotice(ids: string[]) { + if (ids.length === 0) return []; + return [ + `${ids.length} Zenifra models were skipped because they could not be mapped to canonical models.dev metadata.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + missingNotice(paths: string[]) { + if (paths.length === 0) return []; + return [ + `${paths.length} local Zenifra models were absent from the live API and were retained for manual lifecycle review.`, + `Retained local paths: ${paths.map((path) => `\`${path}\``).join(", ")}`, + ]; + }, + async fetchModels() { + return fetchZenifraModels(process.env.ZENIFRA_AI_KEY); + }, + parseModels: parseZenifraModels, + translateModel(model: ZenifraModel, context) { + const existing = context.existing(model.id); + const authored = context.authored(model.id); + const baseModel = authored?.base_model + ?? existing?.base_model + ?? resolveZenifraBaseModel(model.id); + + // A new relay route without canonical metadata is not safe to author as a + // full inline model. Existing entries remain available for manual review. + if ( + existing === undefined + && ( + baseModel === undefined + || model.pricing?.input === undefined + || model.pricing.output === undefined + || model.capabilities?.reasoning === undefined + ) + ) return undefined; + + return { + id: model.id, + model: buildZenifraModel(model, existing, baseModel, authored), + }; + }, +} satisfies SyncProvider; + +export async function fetchZenifraModels( + key: string | undefined, + fetcher: typeof fetch = fetch, +) { + const token = key?.trim(); + const response = await fetcher(API_ENDPOINT, { + headers: token === undefined || token === "" + ? undefined + : { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Zenifra models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export function parseZenifraModels(raw: unknown) { + const models = ZenifraResponse.parse(raw).data; + if (models.length === 0) { + throw new Error("Zenifra returned an empty model catalog; refusing to sync"); + } + return models; +} + +export function resolveZenifraBaseModel(modelID: string) { + const override = CANONICAL_BASE_MODEL_OVERRIDES[modelID]; + if (override !== undefined) return override; + + const bareID = modelID.startsWith("zenifra/") ? modelID.slice("zenifra/".length) : modelID; + return resolveModelMetadataBaseModel(bareID); +} + +export function buildZenifraModel( + model: ZenifraModel, + existing: ExistingModel | undefined, + baseModel = existing?.base_model ?? resolveZenifraBaseModel(model.id), + authored: ExistingModel | undefined = existing, +): SyncedModel { + const input = model.input_modalities === undefined + ? existing?.modalities?.input + : modalities(model.input_modalities, ["text"]); + const output = model.output_modalities === undefined + ? existing?.modalities?.output + : modalities(model.output_modalities, ["text"]); + const capabilities = model.capabilities; + const parameters = model.supported_parameters === undefined + ? undefined + : new Set(model.supported_parameters); + const sourceReasoning = capabilities?.reasoning?.supported; + const reasoning = sourceReasoning + ?? existing?.reasoning + ?? (baseModel === undefined ? false : undefined); + const reasoningOptions = reasoning === true + ? resolveReasoningOptions(model, authored) + : undefined; + + if (reasoning === true && reasoningOptions === undefined && (existing === undefined || baseModel === undefined)) { + throw new MissingReasoningOptionsError( + model.id, + "Zenifra exposes reasoning without a safe control set or authored reasoning_options", + ); + } + + const context = model.context_length + ?? existing?.limit?.context + ?? (baseModel === undefined ? 0 : undefined); + const outputLimit = model.max_output_tokens + ?? existing?.limit?.output + ?? (baseModel === undefined ? 0 : undefined); + const limit = { + context, + input: existing?.limit?.input, + output: outputLimit, + }; + const hostValues = { + attachment: input === undefined ? existing?.attachment : input.some((value) => value !== "text"), + reasoning, + reasoning_options: reasoningOptions, + temperature: parameters === undefined ? existing?.temperature : parameters.has("temperature"), + tool_call: capabilities?.function_calling + ?? (parameters === undefined + ? existing?.tool_call + : parameters.has("tools") || parameters.has("tool_choice")), + structured_output: capabilities?.structured_outputs + ?? capabilities?.response_schema + ?? (parameters === undefined ? existing?.structured_output : parameters.has("structured_outputs")), + status: existing?.status, + interleaved: existing?.interleaved, + provider: { shape: "completions" }, + cost: buildCost(model, existing, reasoning), + limit, + modalities: { input, output }, + }; + + if (baseModel !== undefined) { + return factorBaseModel(baseModel, hostValues, limit, authored?.base_model_omit); + } + + return { + name: existing?.name ?? model.id, + description: existing?.description ?? model.id, + family: existing?.family, + release_date: existing?.release_date ?? dateFromTimestamp(model.created), + last_updated: existing?.last_updated ?? dateFromTimestamp(model.created), + knowledge: existing?.knowledge, + open_weights: existing?.open_weights ?? false, + ...hostValues, + attachment: hostValues.attachment ?? existing?.attachment ?? false, + reasoning: hostValues.reasoning ?? false, + tool_call: hostValues.tool_call ?? existing?.tool_call ?? false, + structured_output: hostValues.structured_output ?? existing?.structured_output ?? false, + limit: { + ...limit, + context: context ?? 0, + output: outputLimit ?? context ?? 0, + }, + modalities: { + input: input ?? ["text"], + output: output ?? ["text"], + }, + } satisfies SyncedFullModel; +} + +function resolveReasoningOptions( + model: ZenifraModel, + authored: ExistingModel | undefined, +): SyncedFullModel["reasoning_options"] { + if (authored?.reasoning_options !== undefined) { + return authored.reasoning_options.flatMap((option) => { + const parsed = ReasoningOption.safeParse(option); + return parsed.success ? [parsed.data] : []; + }); + } + + const levels = model.supported_parameters?.includes("reasoning_effort") + ? model.capabilities?.reasoning?.effort_levels + ?.filter((value): value is ReasoningEffort => REASONING_EFFORTS.has(value as ReasoningEffort)) + : undefined; + if (levels !== undefined && levels.length > 0) { + return [{ type: "effort", values: [...new Set(levels)] }]; + } + + if (model.capabilities?.reasoning?.always_on === true) return []; + return undefined; +} + +function buildCost( + model: ZenifraModel, + existing: ExistingModel | undefined, + reasoning: boolean | undefined, +): SyncedFullModel["cost"] | undefined { + const pricing = model.pricing; + if (pricing === undefined) return clearReasoningCost(existing?.cost, reasoning); + + const input = usd(pricing.input); + const output = usd(pricing.output); + if (input === undefined || output === undefined) return clearReasoningCost(existing?.cost, reasoning); + + const tiers = (pricing.context_tiers ?? []) + .filter((tier) => tier.min_input_tokens > 0) + .sort((a, b) => a.min_input_tokens - b.min_input_tokens) + .map((tier) => ({ + tier: { type: "context" as const, size: tier.min_input_tokens }, + input: usd(tier.input)!, + output: usd(tier.output)!, + cache_read: tier.cache_read_input === undefined ? undefined : usd(tier.cache_read_input), + })) + .filter((tier, index, values) => index === 0 || tier.tier.size > values[index - 1]!.tier.size); + + return { + input, + output, + reasoning: reasoning === false ? undefined : existing?.cost?.reasoning, + cache_read: pricing.cache_read_input === undefined + ? existing?.cost?.cache_read + : usd(pricing.cache_read_input), + cache_write: existing?.cost?.cache_write, + input_audio: existing?.cost?.input_audio, + output_audio: existing?.cost?.output_audio, + tiers: pricing.context_tiers === undefined + ? existing?.cost?.tiers + : tiers.length > 0 + ? tiers + : undefined, + }; +} + +function clearReasoningCost( + cost: SyncedFullModel["cost"] | undefined, + reasoning: boolean | undefined, +) { + if (cost === undefined || reasoning !== false) return cost; + const { reasoning: _reasoning, ...withoutReasoning } = cost; + return withoutReasoning; +} + +function usd(value: number | undefined) { + if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; + return round(value / BRL_PER_USD); +} + +function round(value: number) { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function modalities(values: string[] | undefined, fallback: Modality[]): Modality[] { + const allowed = new Set(["text", "audio", "image", "video", "pdf"]); + const normalized = (values ?? []) + .map((value) => value.toLowerCase()) + .map((value) => (value === "file" ? "pdf" : value)) + .filter((value): value is Modality => allowed.has(value as Modality)); + return [...new Set(normalized.length > 0 ? normalized : fallback)]; +} + +function dateFromTimestamp(timestamp: number) { + return new Date(timestamp * 1_000).toISOString().slice(0, 10); +} diff --git a/packages/core/test/zenifra.test.ts b/packages/core/test/zenifra.test.ts new file mode 100644 index 00000000000..297c6887c85 --- /dev/null +++ b/packages/core/test/zenifra.test.ts @@ -0,0 +1,365 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; + +import { syncProvider, type ExistingModel } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; +import { + buildZenifraModel, + fetchZenifraModels, + parseZenifraModels, + resolveZenifraBaseModel, + zenifra, + type ZenifraModel, +} from "../src/sync/providers/zenifra.js"; + +function zenifraModel(overrides: Partial = {}): ZenifraModel { + return { + id: "zenifra/qwen3.8-flash", + object: "model", + owned_by: "zenifra", + created: 1_677_610_602, + context_length: 1_000_000, + max_output_tokens: 131_072, + pricing: { + input: 0.6, + output: 2, + cache_read_input: 0.1, + unit: "per_million_tokens", + context_tiers: [ + { + min_input_tokens: 0, + max_input_tokens: 256_000, + input: 0.6, + output: 2, + cache_read_input: 0.1, + }, + { + min_input_tokens: 256_001, + max_input_tokens: 1_000_000, + input: 3.4, + output: 20.4, + cache_read_input: 0.34, + }, + ], + }, + capabilities: { + system_messages: true, + response_schema: true, + vision: true, + function_calling: true, + tool_choice: true, + structured_outputs: true, + reasoning: { + supported: true, + always_on: false, + effort_levels: ["low", "medium", "xhigh"], + }, + }, + input_modalities: ["text", "image", "video"], + output_modalities: ["text"], + supported_operations: ["/v1/chat/completions", "/v1/responses"], + supported_parameters: [ + "max_tokens", + "temperature", + "tools", + "tool_choice", + ], + ...overrides, + }; +} + +function existingModel(overrides: ExistingModel = {}): ExistingModel { + return { + base_model: "alibaba/qwen3.8-flash", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + cost: { + input: 1, + output: 2, + cache_read: 0.1, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }, + limit: { context: 100_000, output: 8_000 }, + ...overrides, + }; +} + +test("fetches the public Zenifra catalog without authentication", async () => { + let request: Request | undefined; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + request = input instanceof Request + ? new Request(input, init) + : new Request(input.toString(), init); + return Response.json({ object: "list", data: [zenifraModel()] }); + }) as unknown as typeof fetch; + + const raw = await fetchZenifraModels(undefined, fetcher); + + expect(request?.url).toBe("https://ai.zenifra.com/v1/models"); + expect(request?.headers.get("authorization")).toBeNull(); + expect(raw.data).toHaveLength(1); +}); + +test("adds optional bearer authentication when a Zenifra key is configured", async () => { + let request: Request | undefined; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + request = input instanceof Request + ? new Request(input, init) + : new Request(input.toString(), init); + return Response.json({ object: "list", data: [zenifraModel()] }); + }) as unknown as typeof fetch; + + await fetchZenifraModels("test-key", fetcher); + + expect(request?.headers.get("authorization")).toBe("Bearer test-key"); +}); + +test("rejects an empty Zenifra catalog before destructive sync", () => { + expect(() => parseZenifraModels({ object: "list", data: [] })).toThrow( + "Zenifra returned an empty model catalog; refusing to sync", + ); +}); + +test("maps Zenifra prices, tiers, limits, capabilities, and modalities", () => { + const result = buildZenifraModel( + zenifraModel({ id: "zenifra/test" }), + existingModel({ base_model: undefined }), + ); + + expect(result).toMatchObject({ + attachment: true, + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + temperature: true, + tool_call: true, + structured_output: true, + provider: { shape: "completions" }, + limit: { context: 1_000_000, output: 131_072 }, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + cost: { + input: 0.115385, + output: 0.384615, + cache_read: 0.019231, + tiers: [{ + tier: { type: "context", size: 256_001 }, + input: 0.653846, + output: 3.923077, + cache_read: 0.065385, + }], + }, + }); +}); + +test("derives temperature support from the API parameters", () => { + const result = buildZenifraModel( + zenifraModel({ supported_parameters: ["temperature"] }), + existingModel({ temperature: false }), + ); + + expect(result.temperature).toBe(true); +}); + +test("resolves Zenifra routes to canonical model metadata", () => { + expect(resolveZenifraBaseModel("zenifra/qwen3.8-flash")).toBe("alibaba/qwen3.8-flash"); + expect(resolveZenifraBaseModel("zenifra/deepseek-v4-pro")).toBe("deepseek/deepseek-v4-pro-0813"); +}); + +test("reports new Zenifra routes without canonical metadata", () => { + const model = zenifraModel({ id: "zenifra/new-model" }); + + expect(zenifra.translateModel(model, { + existing: () => undefined, + authored: () => undefined, + })).toBeUndefined(); + expect(zenifra.missingModelID?.(model)).toBe("zenifra/new-model"); +}); + +test("skips a new canonical route when reasoning metadata is omitted", () => { + const model = zenifraModel({ capabilities: undefined }); + + expect(zenifra.translateModel(model, { + existing: () => undefined, + authored: () => undefined, + })).toBeUndefined(); +}); + +test("requires authored controls for an inline reasoner with no safe feed controls", () => { + const model = zenifraModel({ + id: "zenifra/inline-reasoner", + capabilities: { + reasoning: { + supported: true, + effort_levels: ["low", "medium", "xhigh"], + }, + }, + supported_parameters: ["temperature"], + }); + + expect(() => buildZenifraModel( + model, + existingModel({ base_model: undefined, reasoning_options: undefined }), + )).toThrow(MissingReasoningOptionsError); +}); + +test("preserves authored capabilities when optional feed fields are absent", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/inline-model", + capabilities: undefined, + input_modalities: undefined, + output_modalities: undefined, + supported_parameters: undefined, + }), + existingModel({ + base_model: undefined, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + temperature: true, + tool_call: true, + structured_output: true, + reasoning_options: [], + }), + ); + + expect(result).toMatchObject({ + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + temperature: true, + tool_call: true, + structured_output: true, + reasoning_options: [], + }); +}); + +test("does not stamp defaults when a new canonical route omits optional fields", () => { + const result = buildZenifraModel(zenifraModel({ + input_modalities: undefined, + output_modalities: undefined, + context_length: undefined, + max_output_tokens: undefined, + capabilities: undefined, + supported_parameters: undefined, + }), undefined); + + expect(result).toMatchObject({ base_model: "alibaba/qwen3.8-flash" }); + expect(result).not.toHaveProperty("attachment"); + expect(result).not.toHaveProperty("limit"); + expect(result).not.toHaveProperty("modalities"); + expect(result).not.toHaveProperty("tool_call"); + expect(result).not.toHaveProperty("temperature"); +}); + +test("preserves authored pricing fields omitted by a partial feed", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/partial-pricing", + pricing: { input: 0.6, output: 2 }, + }), + existingModel({ + base_model: undefined, + reasoning_options: [], + cost: { + input: 1, + output: 2, + cache_read: 0.5, + cache_write: 0.2, + input_audio: 3, + output_audio: 4, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }, + }), + ); + + expect(result.cost).toMatchObject({ + input: 0.115385, + output: 0.384615, + cache_read: 0.5, + cache_write: 0.2, + input_audio: 3, + output_audio: 4, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }); +}); + +test("clears reasoning cost when the feed disables reasoning", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/non-reasoning", + capabilities: { reasoning: { supported: false } }, + pricing: undefined, + }), + existingModel({ + base_model: undefined, + reasoning: true, + reasoning_options: undefined, + cost: { input: 1, output: 2, reasoning: 0.5 }, + }), + ); + + expect(result.reasoning).toBe(false); + expect(result.cost?.reasoning).toBeUndefined(); +}); + +test("retains missing Zenifra routes instead of deleting on feed omission", () => { + expect(zenifra.deleteMissing).toBe(false); + expect(zenifra.missingNotice?.(["kimi-k3.toml"])).toEqual([ + "1 local Zenifra models were absent from the live API and were retained for manual lifecycle review.", + "Retained local paths: `kimi-k3.toml`", + ]); +}); + +test("sync runner retains local files absent from the Zenifra feed", async () => { + const root = await mkdtemp(path.join(import.meta.dirname, "zenifra-sync-")); + const modelsDir = path.join(root, "models"); + await mkdir(modelsDir, { recursive: true }); + await Bun.write(path.join(modelsDir, "legacy.toml"), `name = "Legacy" +description = "Legacy model retained for lifecycle review" +attachment = false +reasoning = false +tool_call = false +open_weights = false +release_date = "2026-01-01" +last_updated = "2026-01-01" + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] + +[cost] +input = 1 +output = 1 +`); + + try { + const result = await syncProvider({ + ...zenifra, + modelsDir, + fetchModels: async () => ({ + object: "list", + data: [zenifraModel({ + id: "zenifra/unknown", + capabilities: { reasoning: { supported: false } }, + supported_parameters: [], + })], + }), + }, { dryRun: true, openIssues: false }); + + expect(result.deleted).toBe(0); + expect(result.notices).toContain( + "1 local Zenifra models were absent from the live API and were retained for manual lifecycle review.", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml b/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml deleted file mode 100644 index 2b20d088f45..00000000000 --- a/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml +++ /dev/null @@ -1,14 +0,0 @@ -base_model = "alibaba/qwen3.6-35b-a3b" -reasoning = true -reasoning_options = [] - -[modalities] -input = ["text"] -output = ["text"] - -[provider] -shape = "completions" - -[cost] -input = 0.19 -output = 0.48 diff --git a/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml new file mode 100644 index 00000000000..3e7de452fdb --- /dev/null +++ b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml @@ -0,0 +1,23 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Off-peak 14:00-00:00 UTC: BRL/1M input 1.10, output 3.30, cache read 0.14; +# the models.dev cost schema does not represent time-of-day pricing. +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|high|max. +base_model = "deepseek/deepseek-v4-flash-0731" +structured_output = false +reasoning_options = [ + { type = "toggle" }, + { type = "effort", values = ["low", "high", "max"] }, +] + +[limit] +output = 393_216 + +[provider] +shape = "completions" + +[cost] +input = 0.423077 +output = 1.269231 +cache_read = 0.055769 diff --git a/providers/zenifra/models/zenifra/deepseek-v4-pro.toml b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml new file mode 100644 index 00000000000..ed15a9ae266 --- /dev/null +++ b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml @@ -0,0 +1,23 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = high|max (GET /v1/models, 2026-09-22). +# Off-peak 14:00-00:00 UTC: BRL/1M input 4.30, output 8.50, cache read 0.35; +# the models.dev cost schema does not represent time-of-day pricing. +base_model = "deepseek/deepseek-v4-pro-0813" +structured_output = false +reasoning_options = [ + { type = "toggle" }, + { type = "effort", values = ["high", "max"] }, +] + +[limit] +output = 393_216 + +[provider] +shape = "completions" + +[cost] +input = 1.634615 +output = 3.269231 +cache_read = 0.134615 diff --git a/providers/zenifra/models/zenifra/glm-5.1.toml b/providers/zenifra/models/zenifra/glm-5.1.toml new file mode 100644 index 00000000000..78c5779c141 --- /dev/null +++ b/providers/zenifra/models/zenifra/glm-5.1.toml @@ -0,0 +1,23 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). +base_model = "zhipuai/glm-5.1" +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 202_745 + +[provider] +shape = "completions" + +[cost] +input = 1.144231 +output = 4.578846 +cache_read = 0.228846 + +[[cost.tiers]] +tier = { type = "context", size = 32_001 } +input = 1.526923 +output = 5.340385 +cache_read = 0.305769 diff --git a/providers/zenifra/models/zenifra/glm-5.2.toml b/providers/zenifra/models/zenifra/glm-5.2.toml new file mode 100644 index 00000000000..01793067392 --- /dev/null +++ b/providers/zenifra/models/zenifra/glm-5.2.toml @@ -0,0 +1,21 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = high|max. +base_model = "zhipuai/glm-5.2" +structured_output = false +reasoning_options = [ + { type = "toggle" }, + { type = "effort", values = ["high", "max"] }, +] + +[limit] +context = 1_048_576 + +[provider] +shape = "completions" + +[cost] +input = 1.096154 +output = 3.846154 +cache_read = 0.269231 diff --git a/providers/zenifra/models/zenifra/kimi-k2.5.toml b/providers/zenifra/models/zenifra/kimi-k2.5.toml new file mode 100644 index 00000000000..33ca41b3b69 --- /dev/null +++ b/providers/zenifra/models/zenifra/kimi-k2.5.toml @@ -0,0 +1,20 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = false. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. +base_model = "moonshotai/kimi-k2.5" +temperature = true +structured_output = false +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 229_376 +output = 16_384 + +[provider] +shape = "completions" + +[cost] +input = 0.576923 +output = 3.019231 +cache_read = 0.115385 diff --git a/providers/zenifra/models/zenifra/kimi-k2.7-code.toml b/providers/zenifra/models/zenifra/kimi-k2.7-code.toml new file mode 100644 index 00000000000..c690be07a8f --- /dev/null +++ b/providers/zenifra/models/zenifra/kimi-k2.7-code.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Reasoning is always on and no caller control is listed in supported_parameters. +base_model = "moonshotai/kimi-k2.7-code" +temperature = true +reasoning_options = [] + +[limit] +context = 229_376 +output = 16_384 + +[provider] +shape = "completions" + +[cost] +input = 0.903846 +output = 3.730769 +cache_read = 0.067308 diff --git a/providers/zenifra/models/zenifra/kimi-k3.toml b/providers/zenifra/models/zenifra/kimi-k3.toml new file mode 100644 index 00000000000..b107f186347 --- /dev/null +++ b/providers/zenifra/models/zenifra/kimi-k3.toml @@ -0,0 +1,17 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Reasoning is always on; Zenifra advertises effort levels low|high|max. +base_model = "moonshotai/kimi-k3" +temperature = true +reasoning_options = [{ type = "effort", values = ["low", "high", "max"] }] + +[limit] +output = 1_048_576 + +[provider] +shape = "completions" + +[cost] +input = 2.826923 +output = 14.132692 +cache_read = 0.288462 diff --git a/providers/zenifra/models/zenifra/qwen3.6-flash.toml b/providers/zenifra/models/zenifra/qwen3.6-flash.toml new file mode 100644 index 00000000000..3509dfaa992 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.6-flash.toml @@ -0,0 +1,23 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). +base_model = "alibaba/qwen3.6-flash" +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 991_808 + +[provider] +shape = "completions" + +[cost] +input = 0.163462 +output = 0.980769 +cache_read = 0.017308 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 0.653846 +output = 3.923077 +cache_read = 0.065385 diff --git a/providers/zenifra/models/zenifra/qwen3.6-plus.toml b/providers/zenifra/models/zenifra/qwen3.6-plus.toml new file mode 100644 index 00000000000..4d2411a22ba --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.6-plus.toml @@ -0,0 +1,26 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). +base_model = "alibaba/qwen3.6-plus" +structured_output = true + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.275 +output = 1.636538 +cache_read = 0.028846 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 1.092308 +output = 6.540385 +cache_read = 0.111538 + +[limit] +context = 991_808 + +[provider] +shape = "completions" diff --git a/providers/zenifra/models/zenifra/qwen3.7-max.toml b/providers/zenifra/models/zenifra/qwen3.7-max.toml new file mode 100644 index 00000000000..d7a316afc33 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.7-max.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = true; input_modalities = text. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. +base_model = "alibaba/qwen3.7-max" +structured_output = true +reasoning_options = [{ type = "toggle" }] + +[limit] +output = 131_072 + +[provider] +shape = "completions" + +[cost] +input = 1.634615 +output = 4.903846 +cache_read = 0.326923 diff --git a/providers/zenifra/models/zenifra/qwen3.7-plus.toml b/providers/zenifra/models/zenifra/qwen3.7-plus.toml new file mode 100644 index 00000000000..b32c2e3f76c --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.7-plus.toml @@ -0,0 +1,24 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = true. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. +base_model = "alibaba/qwen3.7-plus" +structured_output = true +reasoning_options = [{ type = "toggle" }] + +[limit] +output = 131_072 + +[provider] +shape = "completions" + +[cost] +input = 0.288462 +output = 1.096154 +cache_read = 0.076923 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 0.846154 +output = 3.307692 +cache_read = 0.173077 diff --git a/providers/zenifra/models/zenifra/qwen3.8-flash.toml b/providers/zenifra/models/zenifra/qwen3.8-flash.toml new file mode 100644 index 00000000000..61d91a9d9b1 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.8-flash.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# This model advertises both /v1/chat/completions and /v1/responses. +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|medium|xhigh. +base_model = "alibaba/qwen3.8-flash" +temperature = true + +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "xhigh"] + +[cost] +input = 0.115385 +output = 0.384615 +cache_read = 0.019231 + +[provider] +shape = "completions" diff --git a/providers/zenifra/models/zenifra/qwen3.8-max.toml b/providers/zenifra/models/zenifra/qwen3.8-max.toml new file mode 100644 index 00000000000..87b305b2092 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.8-max.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# This model advertises both /v1/chat/completions and /v1/responses. +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|medium|xhigh. +base_model = "alibaba/qwen3.8-max" +structured_output = true +reasoning_options = [ + { type = "toggle" }, + { type = "effort", values = ["low", "medium", "xhigh"] }, +] + +[modalities] +input = ["text", "image", "video"] + +[provider] +shape = "completions" + +[cost] +input = 1.634615 +output = 4.923077 +cache_read = 0.211538 diff --git a/sync.md b/sync.md index f2bb9a5130e..c2c5dd040ee 100644 --- a/sync.md +++ b/sync.md @@ -24,6 +24,7 @@ The grouped sync targets are available for local convenience, but CI syncs each - `bun models:sync ollama-cloud` syncs Ollama Cloud catalog availability. - `bun models:sync github-copilot` syncs only GitHub Copilot pricing. - `bun models:sync tinfoil` syncs only Tinfoil. +- `bun models:sync zenifra` syncs only Zenifra. - `bun models:sync aggregators --dry-run` prints changes without writing model files. - `bun models:sync aggregators --new-only` creates new model files but skips updates and removals. - `bun models:sync --open-issues` opens GitHub issues for missing models (on by default only when `GITHUB_ACTIONS=true`). @@ -268,6 +269,16 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - New token-priced chat, safety, and embedding models are not created automatically (`skipCreates`); each missing ID opens a deduped GitHub issue for hand-authored metadata. - Per-request tool, TTS, transcription, realtime, and document-processing services are ignored because their pricing cannot be represented by the token-cost schema. +## Zenifra Notes + +- Zenifra is implemented in `packages/core/src/sync/providers/zenifra.ts`. +- Source endpoint: `https://ai.zenifra.com/v1/models`; no authentication is required for the public catalog. `ZENIFRA_AI_KEY` is accepted locally or when the endpoint is configured to require a key. +- The endpoint's prices are BRL per million tokens and are converted to USD using the `5.20 BRL/USD` rate used by the authored catalog. Off-peak prices are not represented because the catalog cost schema has no time-of-day dimension. +- Context tiers, context/output limits, modalities, attachment support, tool calling, structured output, and temperature support come from the endpoint when published. +- Existing authored reasoning options are preserved when the endpoint does not provide an unambiguous wire field. New reasoning models without safe controls open a deduplicated missing-model issue instead of receiving an invented empty option set. +- Model IDs are mapped to canonical `models/` metadata; unknown remote IDs are reported for manual authoring. Zenifra models absent from a successful response are retained for manual lifecycle review because the public feed has no deletion signal. +- An empty response is rejected before syncing so a transient or truncated feed cannot change the local catalog. + ## OpenAI Notes - OpenAI is implemented in `packages/core/src/sync/providers/openai.ts`.