Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/sync-models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ jobs:
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
LLMGATEWAY_API_KEY: ${{ secrets.LLMGATEWAY_API_KEY }}
MERGE_GATEWAY_API_KEY: ${{ secrets.MERGE_GATEWAY_API_KEY }}
NOVITA_API_KEY: ${{ secrets.NOVITA_API_KEY }}
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { llmgateway, llmgatewayProviders } from "./providers/llmgateway.js";
import { mergeGateway } from "./providers/merge-gateway.js";
import { meta } from "./providers/meta.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";
Expand Down Expand Up @@ -86,6 +87,8 @@ export interface SyncProvider<SourceModel> {
skipCreates?: boolean;
/** Report remote-only models skipped by skipCreates as GitHub issues. */
trackMissingModels?: boolean;
/** Maximum share of existing files that may disappear in one sync. */
maxMissingFraction?: number;
deleteMissing?: boolean;
preserveSymlinks?: boolean;
preserveBaseModels?: boolean;
Expand Down Expand Up @@ -166,6 +169,7 @@ export const providers: {
"merge-gateway": SyncProvider<any>;
meta: SyncProvider<any>;
"nano-gpt": SyncProvider<any>;
"novita-ai": SyncProvider<any>;
ofox: SyncProvider<any>;
"ollama-cloud": SyncProvider<any>;
openai: SyncProvider<any>;
Expand Down Expand Up @@ -205,6 +209,7 @@ export const providers: {
"merge-gateway": mergeGateway,
meta,
"nano-gpt": nanoGpt,
"novita-ai": novitaAi,
ofox,
"ollama-cloud": ollamaCloud,
openai,
Expand All @@ -231,6 +236,7 @@ export const groups = {
"llmgateway-providers",
"merge-gateway",
"nano-gpt",
"novita-ai",
"ofox",
"requesty",
"openrouter",
Expand Down Expand Up @@ -381,6 +387,18 @@ export async function syncProvider<SourceModel>(
});
}

if (provider.deleteMissing !== false && provider.maxMissingFraction !== undefined) {
if (provider.maxMissingFraction < 0 || provider.maxMissingFraction > 1) {
throw new Error(`Invalid maxMissingFraction for ${provider.id}`);
}
const absent = [...existing.keys()].filter((file) =>
!desired.has(file) && !missingRemote.has(file.slice(0, -5)) && !missingReasoning.has(file.slice(0, -5))
).length;
if (existing.size > 0 && absent / existing.size > provider.maxMissingFraction) {
throw new Error(`${provider.id} sync would delete ${absent}/${existing.size} existing models; refusing unusually large catalog shrink`);
}
}

const files: SyncResult["files"] = [];
let unchanged = 0;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/sync/missing-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function issueTitle(providerId: string, modelId: string) {
function issueBody(provider: MissingModelIssueTarget, modelId: string, reason?: string) {
return [
reason === undefined
? `The **${provider.name}** catalog sync found remote model \`${modelId}\` that is not in the local catalog.`
? `The **${provider.name}** catalog sync cannot automatically translate remote model \`${modelId}\`. Any existing local entry was left unchanged.`
: `The **${provider.name}** catalog sync is missing reasoning options for remote model \`${modelId}\`. Any existing local entry was left unchanged.`,
"",
`| Field | Value |`,
Expand Down
240 changes: 240 additions & 0 deletions packages/core/src/sync/providers/novita-ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { z } from "zod";

import { describeModel } from "../../describe.js";
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js";

const API_ENDPOINT = "https://api.novita.ai/openai/v1/models";
const BASE_MODEL_ALIASES: Record<string, string> = {
"deepseek/deepseek_v3": "deepseek/deepseek-v3",
};
// Verified per model with Novita chat/completions: disabling thinking removes
// reasoning_content, while enabling it returns reasoning_content.
const VERIFIED_THINKING_TOGGLE = new Set([
"deepseek/deepseek-v4-flash-0731",
"deepseek/deepseek-v4-flash-vision-exp",
"inclusionai/ling-3.0-flash-fin",
"minimax/minimax-m3",
"nvidia/nemotron-3-nano-30b-a3b",
"qwen/qwen3.5-plus",
"qwen/qwen3.6-27b",
"qwen/qwen3.6-35b-a3b",
"qwen/qwen3.6-plus",
"qwen/qwen3.8-27b",
"qwen/qwen3.8-flash",
"qwen/qwen3.8-max",
"tencent/hy3",
"zai-org/glm-5-turbo",
"zai-org/glm-5.3",
"zai-org/glm-5v-turbo",
]);
const VERIFIED_TOGGLE_HEADER = "# Toggle: thinking.type = enabled|disabled\n# Verified with Novita chat/completions on 2026-09-17: disabling removes reasoning_content.\n";
const Price = z.object({ price_per_m_decimal: z.string().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 NovitaAIModel = z.object({
id: z.string().min(1),
object: z.literal("model"),
created: z.number().int().nonnegative(),
owned_by: z.string(),
input_token_price_per_m: z.number().optional(),
output_token_price_per_m: z.number().optional(),
title: z.string().optional(),
display_name: z.string().optional(),
description: z.string().optional(),
context_size: z.number().int().positive().optional(),
max_output_tokens: z.number().int().positive().optional(),
features: z.array(z.string()).optional(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
pricing: Pricing.optional(),
is_tiered_billing: z.boolean().optional(),
tiered_billing_configs: z.array(z.object({
min_tokens: z.number().int().nonnegative(),
max_tokens: z.number().int().positive(),
pricing: Pricing,
}).passthrough()).optional(),
}).passthrough();

export const NovitaAIResponse = z.object({
// Novita's endpoint currently omits the OpenAI-compatible top-level object.
// Keep accepting the standard value if the API adds it later.
object: z.literal("list").optional(),
data: z.array(NovitaAIModel).min(1),
}).passthrough();

export type NovitaAIModel = z.infer<typeof NovitaAIModel>;

function decimalPrice(value: string | undefined) {
if (value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}

type Modality = "text" | "audio" | "image" | "video" | "pdf";

function modalities(values: string[] | undefined, fallback: Modality[] | undefined) {
if (values === undefined || values.length === 0) return fallback;
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
const result = values
.map((value) => value.toLowerCase() === "file" ? "pdf" : value.toLowerCase())
.filter((value): value is Modality => allowed.has(value as Modality));
return result.length > 0 ? [...new Set(result)] : fallback;
}

function dateFromTimestamp(timestamp: number) {
return new Date(timestamp * 1000).toISOString().slice(0, 10);
}

type Cost = NonNullable<ExistingModel["cost"]>;

function price(pricing: z.infer<typeof Pricing> | undefined, existing?: Cost) {
const input = decimalPrice(pricing?.prompt?.price_per_m_decimal);
const output = decimalPrice(pricing?.completion?.price_per_m_decimal);
if (input === undefined || output === undefined) return undefined;
return {
input,
output,
reasoning: existing?.reasoning,
cache_read: decimalPrice(pricing?.input_cache_read?.price_per_m_decimal) ?? existing?.cache_read,
cache_write: decimalPrice(pricing?.input_cache_write?.price_per_m_decimal) ?? existing?.cache_write,
input_audio: existing?.input_audio,
output_audio: existing?.output_audio,
};
}

function cost(model: NovitaAIModel, existing: ExistingModel | undefined) {
if (model.is_tiered_billing !== true) {
// Novita uses zero top-level prices without a pricing object for free models.
if (model.pricing === undefined && model.input_token_price_per_m === 0 && model.output_token_price_per_m === 0) {
return { ...existing?.cost, input: 0, output: 0, tiers: undefined };
}
return price(model.pricing, existing?.cost) ?? existing?.cost;
}
const bands = [...model.tiered_billing_configs ?? []].sort((a, b) => a.min_tokens - b.min_tokens);
if (bands.length === 0 || bands[0]!.min_tokens > 1 || bands.some((band, index) =>
band.max_tokens <= band.min_tokens || (index > 0 && band.min_tokens <= bands[index - 1]!.min_tokens)
)) return existing?.cost;
const base = price(bands[0]!.pricing, existing?.cost);
if (base === undefined || bands.some((band) => price(band.pricing) === undefined)) return existing?.cost;
return {
...base,
tiers: bands.slice(1).map((band) => ({
...price(band.pricing, existing?.cost?.tiers?.find((tier) => tier.tier.size === band.min_tokens))!,
tier: { type: "context" as const, size: band.min_tokens },
})),
};
}

function buildNovitaModel(model: NovitaAIModel, existing: ExistingModel | undefined, resolved: ExistingModel | undefined): SyncedModel | undefined {
const baseModel = existing?.base_model ?? BASE_MODEL_ALIASES[model.id] ?? resolveModelMetadataBaseModel(model.id);
// New provider entries require a lab model. Do not create fabricated inline lab facts.
if (existing === undefined && baseModel === undefined) return undefined;
const name = model.display_name ?? model.title ?? existing?.name ?? model.id;
const input = modalities(model.input_modalities, resolved?.modalities?.input) ?? ["text"];
const output = modalities(model.output_modalities, resolved?.modalities?.output) ?? ["text"];
const features = model.features === undefined ? undefined : new Set(model.features);
const reasoning = features?.has("reasoning") ?? resolved?.reasoning ?? false;
const toolCall = features?.has("function-calling") ?? resolved?.tool_call ?? false;
const structuredOutput = features?.has("structured-outputs") ?? resolved?.structured_output ?? false;
const context = model.context_size ?? resolved?.limit?.context ?? 0;
const outputLimit = model.max_output_tokens ?? resolved?.limit?.output ?? context;
const modelCost = cost(model, existing);
// Novita's GLM-5.3 description claims reasoning cannot be disabled, but
// its chat API returns no reasoning when thinking.type is disabled.
const description = model.id === "zai-org/glm-5.3" ? undefined : model.description;
// DeepSeek R1 is fixed-reasoning on Novita, as with its already curated R1 variants.
const reasoningOptions = existing?.reasoning_options
?? (VERIFIED_THINKING_TOGGLE.has(model.id) ? [{ type: "toggle" as const }] : model.id === "deepseek/deepseek-r1" ? [] : undefined);
const interleaved = existing?.interleaved ?? (VERIFIED_THINKING_TOGGLE.has(model.id) ? { field: "reasoning_content" as const } : undefined);
if (existing === undefined && (modelCost === undefined || (reasoning && reasoningOptions === undefined))) return undefined;
const values: SyncedFullModel = {
name,
description: existing?.description || description || describeModel({ id: model.id, name, reasoning, tool_call: toolCall, structured_output: structuredOutput || undefined, open_weights: existing?.open_weights ?? false, limit: { context, output: outputLimit }, modalities: { input, output } }),
family: existing?.family,
release_date: existing?.release_date ?? dateFromTimestamp(model.created),
last_updated: existing?.last_updated ?? dateFromTimestamp(model.created),
attachment: input.some((value) => value !== "text"),
reasoning,
tool_call: toolCall,
structured_output: structuredOutput,
temperature: existing?.temperature,
open_weights: existing?.open_weights ?? false,
cost: modelCost,
limit: { context, output: outputLimit },
modalities: { input, output },
};
if (baseModel !== undefined) return factorBaseModel(baseModel, {
...values,
// An empty catalog description is not a provider-specific override.
description: existing?.description || description || undefined,
// These are lab facts, not claims made by the Novita catalog endpoint.
open_weights: existing?.open_weights,
release_date: existing?.release_date,
last_updated: existing?.last_updated,
temperature: existing?.temperature,
reasoning_options: reasoningOptions,
interleaved,
}, values.limit, existing?.base_model_omit);
return {
...existing,
...values,
reasoning_options: existing?.reasoning_options,
interleaved,
status: existing?.status,
knowledge: existing?.knowledge,
} as SyncedModel;
}

export async function fetchNovitaAIModels(key: string, fetcher: (url: string, init?: RequestInit) => Promise<Response> = fetch) {
const response = await fetcher(API_ENDPOINT, {
method: "GET",
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) {
throw new Error(`Novita AI models request failed: ${response.status} ${response.statusText}`);
}

return response.json();
}

export const novitaAi = {
id: "novita-ai",
name: "Novita AI",
modelsDir: "providers/novita-ai/models",
// The endpoint exposes the metadata needed to author new provider models.
skipCreates: false,
deleteMissing: true,
trackMissingModels: true,
maxMissingFraction: 0.5,
missingModelID(model) {
return model.id;
},
sourceID(model) {
return model.id;
},
skippedNotice(ids) {
return ids.length === 0 ? [] : [`Novita models needing lab metadata, pricing, or verified reasoning controls: ${ids.join(", ")}`];
},
async fetchModels() {
const key = process.env.NOVITA_API_KEY;
if (key === undefined) throw new Error("Novita AI sync requires NOVITA_API_KEY");
return fetchNovitaAIModels(key);
},
parseModels(raw) {
return NovitaAIResponse.parse(raw).data;
},
translateModel(model, context) {
const translated = buildNovitaModel(model, context.authored(model.id), context.existing(model.id));
return translated === undefined ? undefined : {
id: model.id,
model: translated,
header: VERIFIED_THINKING_TOGGLE.has(model.id) ? VERIFIED_TOGGLE_HEADER : undefined,
};
},
} satisfies SyncProvider<NovitaAIModel>;
Loading
Loading