From 85f9a7eae5a66960224be25bc79c9548cf301437 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 9 Oct 2026 13:23:31 -0500 Subject: [PATCH 1/2] feat(sync): add update-only Novita AI catalog sync --- .github/workflows/sync-models.yml | 1 + packages/core/src/sync/auto-merge.ts | 3 + packages/core/src/sync/index.ts | 4 + packages/core/src/sync/providers/novita-ai.ts | 166 +++++++++++++ packages/core/test/auto-merge.test.ts | 18 ++ packages/core/test/novita-ai.test.ts | 230 ++++++++++++++++++ sync.md | 10 + 7 files changed, 432 insertions(+) create mode 100644 packages/core/src/sync/providers/novita-ai.ts create mode 100644 packages/core/test/novita-ai.test.ts diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index 911080bd365..02ae43d04d9 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -85,6 +85,7 @@ jobs: LLMGATEWAY_API_KEY: ${{ secrets.LLMGATEWAY_API_KEY }} MERGE_GATEWAY_API_KEY: ${{ secrets.MERGE_GATEWAY_API_KEY }} MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + NOVITA_AI_API_KEY: ${{ secrets.NOVITA_AI_API_KEY }} KILO_API_KEY: ${{ secrets.KILO_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} diff --git a/packages/core/src/sync/auto-merge.ts b/packages/core/src/sync/auto-merge.ts index 164a932191e..4bfc9e79833 100644 --- a/packages/core/src/sync/auto-merge.ts +++ b/packages/core/src/sync/auto-merge.ts @@ -57,6 +57,9 @@ export async function classifyAutoMerge( if (created + deleted > MAX_MODEL_CHURN) { reasons.push(`${created + deleted} models created or deleted (limit ${MAX_MODEL_CHURN})`); } + if (models.some((change) => change.status === "created" && change.path.startsWith("providers/novita-ai/models/"))) { + reasons.push("New Novita AI models require manual review"); + } if ( models.some((change) => change.status === "deleted" diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index ed780553a0f..2cda9ebf984 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -32,6 +32,7 @@ import { mergeGateway } from "./providers/merge-gateway.js"; import { meta } from "./providers/meta.js"; import { mistral } from "./providers/mistral.js"; import { nanoGpt } from "./providers/nano-gpt.js"; +import { novitaAi } from "./providers/novita-ai.js"; import { ollamaCloud } from "./providers/ollama-cloud.js"; import { openai } from "./providers/openai.js"; import { ofox } from "./providers/ofox.js"; @@ -168,6 +169,7 @@ export const providers: { meta: SyncProvider; mistral: SyncProvider; "nano-gpt": SyncProvider; + "novita-ai": SyncProvider; ofox: SyncProvider; "ollama-cloud": SyncProvider; openai: SyncProvider; @@ -208,6 +210,7 @@ export const providers: { meta, mistral, "nano-gpt": nanoGpt, + "novita-ai": novitaAi, ofox, "ollama-cloud": ollamaCloud, openai, @@ -234,6 +237,7 @@ export const groups = { "llmgateway-providers", "merge-gateway", "nano-gpt", + "novita-ai", "ofox", "requesty", "openrouter", diff --git a/packages/core/src/sync/providers/novita-ai.ts b/packages/core/src/sync/providers/novita-ai.ts new file mode 100644 index 00000000000..73fc1678dcf --- /dev/null +++ b/packages/core/src/sync/providers/novita-ai.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +import type { ExistingModel, SyncedFullModel, SyncedModel, SyncProvider } from "../index.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import { factorBaseModel } from "./openrouter.js"; + +const ENDPOINT = "https://api.novita.ai/openai/v1/models"; +// Unadvertised/development aliases observed in the 2026-10-09 catalog audit. +// Sao10K is separately deferred until exact IDs have a portable file representation. +const IGNORED_IDS = new Set([ + "bunny", + "dev/glm46", + "deepseek/deepseek-r1/community", + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash-0731-p", + "deepseek/deepseek-v4-pro-0813-p", + "deepseek/deepseek-v4.1-flash-dst", + "deepseek/deepseek-v4.1-flash-p", + "moonshotai/kimi-k3-p", + "zai-org/glm-4.7-h", + "zai-org/glm-5.3-p", +]); + +const Price = z.object({ + price_per_m_decimal: z.string().regex(/^\d+(?:\.\d+)?$/).optional(), + price_per_m: z.number().finite().nonnegative().optional(), +}).passthrough(); + +const Pricing = z.object({ + prompt: Price.optional(), + completion: Price.optional(), + input_cache_read: Price.optional(), + input_cache_write: Price.optional(), +}).passthrough(); + +export const NovitaModel = z.object({ + id: z.string().min(1).refine((id) => + /^[\w.:@+/-]+$/.test(id) + && id.split("/").every((part) => part !== "" && part !== "." && part !== ".."), + "Model ID must be a safe relative path"), + model_type: z.string().optional(), + context_size: z.number().int().nonnegative(), + max_output_tokens: z.number().int().nonnegative().optional(), + input_token_price_per_m: z.number().finite().nonnegative().optional(), + output_token_price_per_m: z.number().finite().nonnegative().optional(), + pricing: Pricing.optional(), + is_tiered_billing: z.boolean().optional(), + tiered_billing_configs: z.array(z.object({ + min_tokens: z.number().int().nonnegative(), + pricing: Pricing, + }).passthrough()).optional(), +}).passthrough(); + +export type NovitaModel = z.infer; + +export function parseNovitaModels(raw: unknown): NovitaModel[] { + const rows = z.object({ data: z.array(NovitaModel).nonempty() }).parse(raw).data; + if (new Set(rows.map((model) => model.id)).size !== rows.length) { + throw new Error("Novita returned duplicate model IDs"); + } + return rows; +} + +export async function fetchNovitaModels( + key = process.env.NOVITA_AI_MODELS_DEV_KEY || process.env.NOVITA_AI_API_KEY, + fetcher: typeof fetch = fetch, +) { + if (!key) throw new Error("Novita sync requires NOVITA_AI_API_KEY (or local NOVITA_AI_MODELS_DEV_KEY)"); + const response = await fetcher(ENDPOINT, { headers: { Authorization: `Bearer ${key}` } }); + if (!response.ok) throw new Error(`Novita models request failed: ${response.status}`); + return response.json(); +} + +// Decimal fields are already USD/MTok; legacy integers are scaled by 10,000. +function price(value: z.infer | undefined, legacy?: number): number | undefined { + const scaled = value?.price_per_m ?? legacy; + const amount = value?.price_per_m_decimal !== undefined + ? Number(value.price_per_m_decimal) + : scaled === undefined ? undefined : scaled / 10_000; + if (amount !== undefined && !Number.isFinite(amount)) throw new Error("Invalid Novita price"); + return amount; +} + +function eligible(model: NovitaModel) { + return !IGNORED_IDS.has(model.id) + && !/^(?:dev|pa)\//.test(model.id) + && !model.id.toLowerCase().startsWith("sao10k/") + && (model.model_type === undefined || model.model_type === "chat") + && model.context_size > 0 && (model.max_output_tokens ?? 0) > 0 + && price(model.pricing?.prompt, model.input_token_price_per_m) !== undefined + && price(model.pricing?.completion, model.output_token_price_per_m) !== undefined; +} + +function cost(pricing: z.infer | undefined, current: ExistingModel["cost"], input?: number, output?: number) { + const result = { + ...current, + input: price(pricing?.prompt, input) ?? current?.input, + output: price(pricing?.completion, output) ?? current?.output, + }; + if (result.input === undefined || result.output === undefined) throw new Error("Novita pricing is incomplete"); + for (const [field, source] of [["cache_read", "input_cache_read"], ["cache_write", "input_cache_write"]] as const) { + const amount = price(pricing?.[source]); + // Zero optional fields may be placeholders, not new free-cache capabilities. + if (amount !== undefined && (amount > 0 || current?.[field] !== undefined)) result[field] = amount; + } + return result as NonNullable; +} + +export function buildNovitaModel(model: NovitaModel, existing: ExistingModel, authored = existing): SyncedModel { + if (existing.reasoning === true && existing.reasoning_options === undefined) { + throw new MissingReasoningOptionsError(model.id, "Novita inventory exposes no reasoning controls; author reasoning_options before syncing this route"); + } + const nextCost = cost(model.pricing, existing.cost, model.input_token_price_per_m, model.output_token_price_per_m); + if (model.is_tiered_billing === false) delete nextCost.tiers; + if (model.is_tiered_billing === true) { + const tiers = [...model.tiered_billing_configs ?? []].sort((a, b) => a.min_tokens - b.min_tokens); + if (tiers.length === 0 || tiers[0]!.min_tokens > 1 || new Set(tiers.map((tier) => tier.min_tokens)).size !== tiers.length) { + throw new Error(`Novita ${model.id} has missing or duplicate pricing tiers`); + } + const base = cost(tiers[0]!.pricing, nextCost); + Object.assign(nextCost, base); + nextCost.tiers = tiers.slice(1).map((tier) => { + const previous = existing.cost?.tiers?.find((entry) => entry.tier.size === tier.min_tokens); + const { tier: _tier, ...previousRates } = previous ?? {}; + const { tiers: _tiers, ...rates } = cost(tier.pricing, previous === undefined ? undefined : previousRates as typeof nextCost); + return { tier: { type: "context" as const, size: tier.min_tokens }, ...rates }; + }); + if (nextCost.tiers.length === 0) delete nextCost.tiers; + } + const limit = { ...existing.limit, context: model.context_size, output: model.max_output_tokens! }; + const { base_model: baseModel, base_model_omit: omit, ...current } = authored; + const values = { ...current, cost: nextCost, limit } as SyncedFullModel; + return baseModel === undefined ? values : factorBaseModel(baseModel, values, limit, omit); +} + +export const novitaAi = { + id: "novita-ai", + name: "Novita AI", + modelsDir: "providers/novita-ai/models", + skipCreates: true, + trackMissingModels: true, + // Account-scoped inventory omits working/non-chat routes. Never delete on absence. + deleteMissing: false, + fetchModels: fetchNovitaModels, + parseModels: parseNovitaModels, + sourceID(model) { + return eligible(model) ? model.id : undefined; + }, + translateModel(model, context) { + if (!eligible(model)) return undefined; + const existing = context.existing(model.id); + const authored = context.authored(model.id); + if (existing === undefined || authored === undefined) return undefined; + if (existing.type !== undefined && existing.type !== "chat") { + return { id: model.id, model: authored as SyncedModel }; + } + // Only prices/limits are API-authoritative here. Preserve curated capabilities, + // reasoning controls, interleaving, dates, descriptions, and request metadata. + return { id: model.id, model: buildNovitaModel(model, existing, authored) }; + }, + skippedNotice(ids) { + return ids.length === 0 ? [] : [ + `New Novita chat models require manual catalog review (missing-model issue fixer): ${ids.join(", ")}`, + ]; + }, +} satisfies SyncProvider; diff --git a/packages/core/test/auto-merge.test.ts b/packages/core/test/auto-merge.test.ts index 1df3be7351f..cde0d9333cd 100644 --- a/packages/core/test/auto-merge.test.ts +++ b/packages/core/test/auto-merge.test.ts @@ -31,6 +31,24 @@ test("requires manual review for bulk additions", async () => { expect(decision.reasons).toContain("11 models created (limit 10)"); }); +test("requires manual review for any new Novita model, including non-reasoners", async () => { + const decision = await classifyAutoMerge( + [{ status: "created", path: "providers/novita-ai/models/new-model.toml" }], + async () => fullModel(false), + ); + expect(decision.safe).toBe(false); + expect(decision.reasons).toContain("New Novita AI models require manual review"); +}); + +test("allows existing Novita price/limit updates without changing reasoning controls", async () => { + const decision = await classifyAutoMerge( + [{ status: "updated", path: "providers/novita-ai/models/reasoner.toml" }], + async () => `${fullModel(true, 'reasoning_options = [{ type = "toggle" }]')}\n[cost]\ninput = 1\n`, + async () => `${fullModel(true, 'reasoning_options = [{ type = "toggle" }]')}\n[cost]\ninput = 2\n`, + ); + expect(decision.safe).toBe(true); +}); + test("requires manual review for Cloudflare AI Gateway deletions", async () => { const decision = await classifyAutoMerge([ { diff --git a/packages/core/test/novita-ai.test.ts b/packages/core/test/novita-ai.test.ts new file mode 100644 index 00000000000..8a63d67130a --- /dev/null +++ b/packages/core/test/novita-ai.test.ts @@ -0,0 +1,230 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +import { groups, providers, syncProvider, type ExistingModel } from "../src/sync/index.js"; +import { + buildNovitaModel, + fetchNovitaModels, + novitaAi, + parseNovitaModels, + type NovitaModel, +} from "../src/sync/providers/novita-ai.js"; + +const row = (overrides: Partial = {}): NovitaModel => ({ + id: "qwen/qwen3.8-max", + model_type: "chat", + context_size: 1_000_000, + max_output_tokens: 131_072, + input_token_price_per_m: 20_000, + output_token_price_per_m: 60_000, + is_tiered_billing: false, + ...overrides, +}); + +const authored: ExistingModel = { + base_model: "alibaba/qwen3.8-max", + structured_output: true, + reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["low", "medium", "xhigh"] }], + interleaved: { field: "reasoning_content" }, + modalities: { input: ["text", "image", "video"] } as ExistingModel["modalities"], + cost: { input: 2, output: 6, cache_read: 0.25, cache_write: 2.5, input_audio: 1 }, +}; + +const existing: ExistingModel = { + ...Bun.TOML.parse(await Bun.file("models/alibaba/qwen3.8-max.toml").text()), + ...authored, + limit: { context: 1_000_000, output: 131_072 }, +} as ExistingModel; + +const context = (present = true) => ({ + existing: () => present ? existing : undefined, + authored: () => present ? authored : undefined, +}); + +test("registers update-only hourly sync and never deletes absent entries", () => { + expect(providers["novita-ai"]).toBe(novitaAi); + expect(groups.aggregators).toContain("novita-ai"); + expect(novitaAi.skipCreates).toBe(true); + expect(novitaAi.trackMissingModels).toBe(true); + expect(novitaAi.deleteMissing).toBe(false); +}); + +test("fetches the authenticated catalog and rejects HTTP failures", async () => { + let request: { url: string; auth: string | null } | undefined; + const fetcher = (async (input, init) => { + request = { url: String(input), auth: new Headers(init?.headers).get("authorization") }; + return Response.json({ data: [row()] }); + }) as typeof fetch; + await expect(fetchNovitaModels("test-key", fetcher)).resolves.toMatchObject({ data: [row()] }); + expect(request).toEqual({ url: "https://api.novita.ai/openai/v1/models", auth: "Bearer test-key" }); + await expect(fetchNovitaModels("", fetcher)).rejects.toThrow("NOVITA_AI_API_KEY"); + await expect(fetchNovitaModels("test-key", (async (_input, _init) => new Response("unauthorized", { status: 401 })) as typeof fetch)) + .rejects.toThrow("401"); +}); + +test("uses the local key when set and the CI key otherwise", async () => { + const previous = [process.env.NOVITA_AI_MODELS_DEV_KEY, process.env.NOVITA_AI_API_KEY]; + const auth: Array = []; + const fetcher = (async (_input, init) => { + auth.push(new Headers(init?.headers).get("authorization")); + return Response.json({ data: [row()] }); + }) as typeof fetch; + try { + process.env.NOVITA_AI_MODELS_DEV_KEY = "local-test-key"; + process.env.NOVITA_AI_API_KEY = "ci-test-key"; + await fetchNovitaModels(undefined, fetcher); + delete process.env.NOVITA_AI_MODELS_DEV_KEY; + await fetchNovitaModels(undefined, fetcher); + delete process.env.NOVITA_AI_API_KEY; + await expect(fetchNovitaModels(undefined, fetcher)).rejects.toThrow("requires"); + expect(auth).toEqual(["Bearer local-test-key", "Bearer ci-test-key"]); + } finally { + for (const [index, key] of ["NOVITA_AI_MODELS_DEV_KEY", "NOVITA_AI_API_KEY"].entries()) { + if (previous[index] === undefined) delete process.env[key]; + else process.env[key] = previous[index]; + } + } +}); + +test("rejects empty, duplicate, malformed, and unsafe inventories", () => { + expect(() => parseNovitaModels({ data: [] })).toThrow(); + expect(() => parseNovitaModels({ data: [row(), row()] })).toThrow("duplicate"); + for (const id of ["../model", "qwen/../model", "/model", "qwen//model", "model\n", "model\\file"]) { + expect(() => parseNovitaModels({ data: [row({ id })] })).toThrow(); + } + expect(() => parseNovitaModels({ data: [row({ input_token_price_per_m: -1 })] })).toThrow(); + expect(() => parseNovitaModels({ data: [row({ pricing: { prompt: { price_per_m_decimal: "" } } })] })).toThrow(); + expect(parseNovitaModels({ data: [row({ features: ["reasoning"], status: 1 } as Partial)] })).toHaveLength(1); +}); + +test("tracks new eligible models without constructing unreviewed TOMLs", () => { + const source = row({ id: "qwen/qwen-future-model" }); + expect(novitaAi.sourceID(source)).toBe(source.id); + expect(novitaAi.translateModel(source, context(false))).toBeUndefined(); +}); + +test("preserves existing non-chat entries even if the inventory mislabels them as chat", () => { + const translated = novitaAi.translateModel(row(), { + existing: () => ({ ...existing, type: "embedding" }), + authored: () => authored, + }); + expect(translated?.id).toBe(row().id); + expect(translated?.model === authored).toBe(true); +}); + +test("silently skips deferred, internal, non-chat, zero-limit, and unpriced rows", () => { + const rows = [ + row({ id: "Sao10K/L3-8B-Stheno-v3.2" }), + row({ id: "sao10k/l3-70b-euryale-v2.1" }), + row({ id: "deepseek/deepseek-v4.1-flash-p" }), + row({ id: "deepseek/deepseek-v4-flash" }), + row({ id: "dev/glm46" }), + row({ id: "dev/new-test-route" }), + row({ id: "pa/gpt-5.6-sol" }), + row({ model_type: "embedding" }), + row({ context_size: 0 }), + row({ max_output_tokens: 0 }), + row({ max_output_tokens: undefined }), + row({ input_token_price_per_m: undefined, output_token_price_per_m: undefined }), + ]; + for (const source of rows) { + expect(novitaAi.sourceID(source)).toBeUndefined(); + expect(novitaAi.translateModel(source, context())).toBeUndefined(); + } + expect(novitaAi.sourceID(row({ input_token_price_per_m: 0, output_token_price_per_m: 0 }))).toBeDefined(); +}); + +test("updates legacy scaled prices and limits without restating base metadata", () => { + const synced = buildNovitaModel(row({ input_token_price_per_m: 15_000, max_output_tokens: 64_000 }), existing, authored); + expect(synced).toMatchObject({ + base_model: authored.base_model, + cost: { input: 1.5, output: 6, cache_read: 0.25, cache_write: 2.5, input_audio: 1 }, + limit: { output: 64_000 }, + reasoning_options: authored.reasoning_options, + interleaved: authored.interleaved, + }); + expect(synced.limit?.context).toBeUndefined(); + for (const field of ["name", "description", "release_date", "last_updated", "reasoning", "open_weights"]) { + expect(synced).not.toHaveProperty(field); + } +}); + +test("prefers decimal USD fields and preserves curated fields absent from inventory", () => { + const synced = buildNovitaModel(row({ pricing: { + prompt: { price_per_m_decimal: "0.435", price_per_m: 99999 }, + completion: { price_per_m_decimal: "0.87" }, + input_cache_read: { price_per_m_decimal: "0.0036" }, + } }), existing, authored); + expect(synced.cost).toMatchObject({ input: 0.435, output: 0.87, cache_read: 0.0036, cache_write: 2.5, input_audio: 1 }); +}); + +test("does not invent free cache support from zero placeholders", () => { + const local = { ...authored, cost: { input: 2, output: 6 } }; + const synced = buildNovitaModel(row({ pricing: { + input_cache_read: { price_per_m: 0 }, input_cache_write: { price_per_m_decimal: "0" }, + } }), { ...existing, cost: local.cost }, local); + expect(synced.cost).toEqual({ input: 2, output: 6 }); +}); + +test("does not change curated reasoning or modalities based on inventory labels", () => { + const source = row({ features: ["reasoning"], input_modalities: ["text", "image"] } as Partial); + const local = { ...authored, reasoning: false, reasoning_options: undefined, modalities: { input: ["text"], output: ["text"] } } as ExistingModel; + const synced = buildNovitaModel(source, { ...existing, ...local }, local); + expect(synced.reasoning).toBe(false); + expect(synced.reasoning_options).toBeUndefined(); + expect(synced.modalities).toEqual({ input: ["text"] }); +}); + +test("does not replace missing reasoning controls with an empty placeholder", () => { + expect(() => buildNovitaModel(row(), { ...existing, reasoning: true, reasoning_options: undefined }, { ...authored, reasoning_options: undefined })) + .toThrow("author reasoning_options"); +}); + +test("syncs context tiers, preserving optional tier pricing and normalizing flat pricing", () => { + const tiered = row({ is_tiered_billing: true, tiered_billing_configs: [ + { min_tokens: 524_288, pricing: { prompt: { price_per_m_decimal: "0.6" }, completion: { price_per_m_decimal: "2.4" }, input_cache_read: { price_per_m_decimal: "0.12" } } }, + { min_tokens: 1, pricing: { prompt: { price_per_m_decimal: "0.3" }, completion: { price_per_m_decimal: "1.2" }, input_cache_read: { price_per_m_decimal: "0.06" } } }, + ] }); + const local = { ...authored, cost: { ...authored.cost!, tiers: [{ tier: { type: "context" as const, size: 524_288 }, input: 1, output: 2, input_audio: 3 }] } }; + const synced = buildNovitaModel(tiered, { ...existing, cost: local.cost }, local); + expect(synced.cost).toMatchObject({ input: 0.3, output: 1.2, cache_read: 0.06, tiers: [ + { tier: { type: "context", size: 524_288 }, input: 0.6, output: 2.4, cache_read: 0.12, input_audio: 3 }, + ] }); + expect(buildNovitaModel(row(), { ...existing, cost: local.cost }, local).cost?.tiers).toBeUndefined(); + expect(buildNovitaModel(row({ is_tiered_billing: undefined }), { ...existing, cost: local.cost }, local).cost?.tiers).toEqual(local.cost.tiers); + expect(() => buildNovitaModel(row({ is_tiered_billing: true }), existing, authored)).toThrow("missing or duplicate"); + expect(() => buildNovitaModel(row({ is_tiered_billing: true, tiered_billing_configs: [tiered.tiered_billing_configs![0]!] }), existing, authored)).toThrow("missing or duplicate"); + expect(() => buildNovitaModel(row({ is_tiered_billing: true, tiered_billing_configs: [tiered.tiered_billing_configs![0]!, tiered.tiered_billing_configs![0]!] }), existing, authored)).toThrow("missing or duplicate"); +}); + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +test("runner preserves absent entries and headers, reports new models, and is idempotent", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "novita-sync-test-")); + roots.push(root); + const modelsDir = path.join(root, "providers/novita-ai/models"); + await mkdir(path.join(modelsDir, "qwen"), { recursive: true }); + await mkdir(path.join(root, "models/alibaba"), { recursive: true }); + await Bun.write(path.join(root, "models/alibaba/qwen3.8-max.toml"), await Bun.file("models/alibaba/qwen3.8-max.toml").text()); + const header = "# Toggle: thinking.type = enabled|disabled\n# Curated source header\n"; + const initial = header + 'base_model = "alibaba/qwen3.8-max"\nreasoning_options = [{ type = "toggle" }]\n[cost]\ninput = 2\noutput = 6\n'; + const file = path.join(modelsDir, "qwen/qwen3.8-max.toml"); + const absent = path.join(modelsDir, "qwen/unlisted.toml"); + await Bun.write(file, initial); + await Bun.write(absent, initial); + const provider = { ...novitaAi, modelsDir, fetchModels: async () => ({ data: [row({ max_output_tokens: 64_000 }), row({ id: "qwen/new-model" })] }) }; + const result = await syncProvider(provider); + expect(result).toMatchObject({ created: 0, updated: 1, deleted: 0, unchanged: 1 }); + expect(result.notices.join("\n")).toContain("qwen/new-model"); + expect(await Bun.file(absent).text()).toBe(initial); + expect(await Bun.file(path.join(modelsDir, "qwen/new-model.toml")).exists()).toBe(false); + const content = await Bun.file(file).text(); + expect(content.startsWith(header)).toBe(true); + expect(Bun.TOML.parse(content)).toMatchObject({ base_model: "alibaba/qwen3.8-max", limit: { output: 64_000 }, reasoning_options: [{ type: "toggle" }] }); + expect(await syncProvider(provider)).toMatchObject({ created: 0, updated: 0, deleted: 0, unchanged: 2 }); +}); diff --git a/sync.md b/sync.md index 29859c5e83a..f6521e5b43c 100644 --- a/sync.md +++ b/sync.md @@ -248,6 +248,16 @@ GitHub Copilot is implemented in `packages/core/src/sync/providers/github-copilo - Unmatched rows open missing-model issues, and local entries missing from the source are kept. - When removing a fully retired Copilot model, add its pricing-table slug to `IGNORED_ROWS` so stale pricing rows cannot trigger translation or missing-model issues. Models still served to some subscribers (such as Sonnet 4.6 on annual plans) remain eligible. +## Novita AI Notes + +- Source: `https://api.novita.ai/openai/v1/models`. +- CI key: `NOVITA_AI_API_KEY`; locally `NOVITA_AI_MODELS_DEV_KEY` takes precedence when set. +- Sync existing chat models' input/output/cache prices, context/output limits, and context pricing tiers only. Preserve curated capabilities, reasoning controls, dates, descriptions, optional pricing, and leading wire/source comments. +- Decimal `price_per_m_decimal` fields are USD/MTok; legacy integer fields are scaled by 10,000. +- New eligible models use the existing missing-model issue-fixer pipeline to open PRs for manual review; Novita model creations cannot auto-merge. Price/limit-only updates use the normal auto-merge policy. +- Never delete entries absent from the account-scoped inventory. In particular, the list omits working embedding/reranking routes. These remain hand-authored. +- Skip deferred Sao10K routes, known unadvertised/development aliases, non-chat rows, zero-limit placeholders, and unpriced rows. + ## Mistral Notes Mistral is implemented in `packages/core/src/sync/providers/mistral.ts`. From 5f9f8b962849f74dba994282db430e6a0e95694e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 9 Oct 2026 13:29:31 -0500 Subject: [PATCH 2/2] refactor(novita-ai): use standard sync review policy --- packages/core/src/sync/auto-merge.ts | 3 --- packages/core/src/sync/providers/novita-ai.ts | 5 +---- packages/core/test/auto-merge.test.ts | 18 ------------------ packages/core/test/novita-ai.test.ts | 5 +---- sync.md | 2 +- 5 files changed, 3 insertions(+), 30 deletions(-) diff --git a/packages/core/src/sync/auto-merge.ts b/packages/core/src/sync/auto-merge.ts index 4bfc9e79833..164a932191e 100644 --- a/packages/core/src/sync/auto-merge.ts +++ b/packages/core/src/sync/auto-merge.ts @@ -57,9 +57,6 @@ export async function classifyAutoMerge( if (created + deleted > MAX_MODEL_CHURN) { reasons.push(`${created + deleted} models created or deleted (limit ${MAX_MODEL_CHURN})`); } - if (models.some((change) => change.status === "created" && change.path.startsWith("providers/novita-ai/models/"))) { - reasons.push("New Novita AI models require manual review"); - } if ( models.some((change) => change.status === "deleted" diff --git a/packages/core/src/sync/providers/novita-ai.ts b/packages/core/src/sync/providers/novita-ai.ts index 73fc1678dcf..69a51633f30 100644 --- a/packages/core/src/sync/providers/novita-ai.ts +++ b/packages/core/src/sync/providers/novita-ai.ts @@ -34,10 +34,7 @@ const Pricing = z.object({ }).passthrough(); export const NovitaModel = z.object({ - id: z.string().min(1).refine((id) => - /^[\w.:@+/-]+$/.test(id) - && id.split("/").every((part) => part !== "" && part !== "." && part !== ".."), - "Model ID must be a safe relative path"), + id: z.string().min(1), model_type: z.string().optional(), context_size: z.number().int().nonnegative(), max_output_tokens: z.number().int().nonnegative().optional(), diff --git a/packages/core/test/auto-merge.test.ts b/packages/core/test/auto-merge.test.ts index cde0d9333cd..1df3be7351f 100644 --- a/packages/core/test/auto-merge.test.ts +++ b/packages/core/test/auto-merge.test.ts @@ -31,24 +31,6 @@ test("requires manual review for bulk additions", async () => { expect(decision.reasons).toContain("11 models created (limit 10)"); }); -test("requires manual review for any new Novita model, including non-reasoners", async () => { - const decision = await classifyAutoMerge( - [{ status: "created", path: "providers/novita-ai/models/new-model.toml" }], - async () => fullModel(false), - ); - expect(decision.safe).toBe(false); - expect(decision.reasons).toContain("New Novita AI models require manual review"); -}); - -test("allows existing Novita price/limit updates without changing reasoning controls", async () => { - const decision = await classifyAutoMerge( - [{ status: "updated", path: "providers/novita-ai/models/reasoner.toml" }], - async () => `${fullModel(true, 'reasoning_options = [{ type = "toggle" }]')}\n[cost]\ninput = 1\n`, - async () => `${fullModel(true, 'reasoning_options = [{ type = "toggle" }]')}\n[cost]\ninput = 2\n`, - ); - expect(decision.safe).toBe(true); -}); - test("requires manual review for Cloudflare AI Gateway deletions", async () => { const decision = await classifyAutoMerge([ { diff --git a/packages/core/test/novita-ai.test.ts b/packages/core/test/novita-ai.test.ts index 8a63d67130a..8679ce0ad0d 100644 --- a/packages/core/test/novita-ai.test.ts +++ b/packages/core/test/novita-ai.test.ts @@ -88,12 +88,9 @@ test("uses the local key when set and the CI key otherwise", async () => { } }); -test("rejects empty, duplicate, malformed, and unsafe inventories", () => { +test("rejects empty, duplicate, and malformed inventories", () => { expect(() => parseNovitaModels({ data: [] })).toThrow(); expect(() => parseNovitaModels({ data: [row(), row()] })).toThrow("duplicate"); - for (const id of ["../model", "qwen/../model", "/model", "qwen//model", "model\n", "model\\file"]) { - expect(() => parseNovitaModels({ data: [row({ id })] })).toThrow(); - } expect(() => parseNovitaModels({ data: [row({ input_token_price_per_m: -1 })] })).toThrow(); expect(() => parseNovitaModels({ data: [row({ pricing: { prompt: { price_per_m_decimal: "" } } })] })).toThrow(); expect(parseNovitaModels({ data: [row({ features: ["reasoning"], status: 1 } as Partial)] })).toHaveLength(1); diff --git a/sync.md b/sync.md index f6521e5b43c..46c6a98f9c5 100644 --- a/sync.md +++ b/sync.md @@ -254,7 +254,7 @@ GitHub Copilot is implemented in `packages/core/src/sync/providers/github-copilo - CI key: `NOVITA_AI_API_KEY`; locally `NOVITA_AI_MODELS_DEV_KEY` takes precedence when set. - Sync existing chat models' input/output/cache prices, context/output limits, and context pricing tiers only. Preserve curated capabilities, reasoning controls, dates, descriptions, optional pricing, and leading wire/source comments. - Decimal `price_per_m_decimal` fields are USD/MTok; legacy integer fields are scaled by 10,000. -- New eligible models use the existing missing-model issue-fixer pipeline to open PRs for manual review; Novita model creations cannot auto-merge. Price/limit-only updates use the normal auto-merge policy. +- New eligible models use the existing missing-model issue-fixer pipeline, which opens separate PRs without enabling auto-merge. The update-only sync PRs use the normal auto-merge policy; no provider-specific exception is needed. - Never delete entries absent from the account-scoped inventory. In particular, the list omits working embedding/reranking routes. These remain hand-authored. - Skip deferred Sao10K routes, known unadvertised/development aliases, non-chat rows, zero-limit placeholders, and unpriced rows.