diff --git a/models/alibaba/qwen3-30b-a3b-instruct-2507.toml b/models/alibaba/qwen3-30b-a3b-instruct-2507.toml new file mode 100644 index 00000000000..94b773e0da2 --- /dev/null +++ b/models/alibaba/qwen3-30b-a3b-instruct-2507.toml @@ -0,0 +1,28 @@ +# Sources (accessed 2026-09-10): +# https://huggingface.co/Qwen/Qwen3-30B-A3B-Instruct-2507 +# Hub createdAt 2025-07-28 is the release; lastModified 2025-09-17 is the latest revision. + +name = "Qwen3 30B A3B Instruct 2507" +description = "Updated non-thinking Qwen3 MoE with long context for instruction following and tool use" +family = "qwen" +release_date = "2025-07-28" +last_updated = "2025-09-17" +attachment = false +reasoning = false +temperature = true +tool_call = true +structured_output = true +open_weights = true +license = "Apache 2.0" + +[limit] +context = 262_144 +output = 16_384 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Hugging Face" +url = "https://huggingface.co/Qwen/Qwen3-30B-A3B-Instruct-2507" diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index e6c30874235..911ae65fb89 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -27,6 +27,7 @@ import { kilo } from "./providers/kilo.js"; import { llmgateway, llmgatewayProviders } from "./providers/llmgateway.js"; import { mergeGateway } from "./providers/merge-gateway.js"; import { meta } from "./providers/meta.js"; +import { nebul } from "./providers/nebul.js"; import { nanoGpt } from "./providers/nano-gpt.js"; import { ollamaCloud } from "./providers/ollama-cloud.js"; import { openai } from "./providers/openai.js"; @@ -153,6 +154,7 @@ export const providers: { "llmgateway-providers": SyncProvider; "merge-gateway": SyncProvider; meta: SyncProvider; + nebul: SyncProvider; "nano-gpt": SyncProvider; ofox: SyncProvider; "ollama-cloud": SyncProvider; @@ -189,6 +191,7 @@ export const providers: { "llmgateway-providers": llmgatewayProviders, "merge-gateway": mergeGateway, meta, + nebul, "nano-gpt": nanoGpt, ofox, "ollama-cloud": ollamaCloud, @@ -222,7 +225,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "github-copilot", "google", "hyper", "meta", "nebul", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/nebul.ts b/packages/core/src/sync/providers/nebul.ts new file mode 100644 index 00000000000..b3010f0a5c1 --- /dev/null +++ b/packages/core/src/sync/providers/nebul.ts @@ -0,0 +1,212 @@ +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import { factorBaseModel, modelMetadata } from "./openrouter.js"; + +const API_ENDPOINT = "https://api.inference.nebul.io/model/info"; +const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); + +// Served org prefix -> models/ metadata namespace (HF org names differ from catalog labs). +const ORG_TO_MODEL_PROVIDER: Record = { + "deepseek-ai": "deepseek", + google: "google", + "meta-models": "meta", + mistralai: "mistral", + moonshotai: "moonshotai", + nvidia: "nvidia", + openai: "openai", + Qwen: "alibaba", + "zai-org": "zhipuai", +}; + +// Served IDs whose canonical metadata lives under a differently-named lab entry. +const BASE_MODEL_ALIASES: Record = { + "mistralai/Mistral-Large-3-675B-Instruct-2512": "mistral/mistral-large-2512", + "mistralai/Mistral-Medium-3.5-128B": "mistral/mistral-medium-2604", +}; + +// The synthetic health-check model and serving artifacts that must not enter the catalog. +const PING_MODEL = "Nebul/Ping"; +const DENYLIST = /OCR|Qwen3Guard/i; + +// Deprecated server-side (descriptions point at GLM-5.3) but not flagged by /model/info. +const DEPRECATED = new Set(["zai-org/GLM-5.1-FP8", "zai-org/GLM-5.2-FP8"]); + +const EffortValues = z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"]); + +const ModelInfo = z.object({ + description: z.string().nullable().optional(), + huggingface_id: z.string().nullable().optional(), + input_cost_per_1m_tokens: z.number().nullable().optional(), + output_cost_per_1m_tokens: z.number().nullable().optional(), + cache_read_input_cost_per_1m_tokens: z.number().nullable().optional(), + max_input_tokens: z.number().nullable().optional(), + mode: z.string().nullable(), + model_type: z.string().nullable(), + reasoning_efforts: z.array(EffortValues).nullable().optional(), +}).passthrough(); + +export const NebulEntry = z.object({ + model_info: ModelInfo, + model_name: z.string().min(1), +}).passthrough(); + +export const NebulResponse = z.object({ + data: z.array(NebulEntry), +}).passthrough(); + +export type NebulEntry = z.infer; + +export const nebul = { + id: "nebul", + name: "Nebul", + modelsDir: "providers/nebul/models", + async fetchModels() { + const response = await fetch(API_ENDPOINT); + if (!response.ok) { + throw new Error(`Nebul models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + }, + parseModels(raw) { + const data = NebulResponse.parse(raw).data; + // An empty catalog is an upstream fault; syncing it would delete every + // local model file via the delete-missing pass, so fail loudly instead. + if (data.length === 0) { + throw new Error("Nebul returned an empty model catalog"); + } + // Same failure mode if the response shape drifts and no entry matches the + // chat-model filter anymore (e.g. renamed model_type/mode values). + if (!data.some(isCatalogChatModel)) { + throw new Error("Nebul returned no usable chat models"); + } + return data; + }, + // Unauthenticated /model/info is the authoritative catalog: entries removed + // server-side are removed here (deleteMissing defaults on), and new resolvable + // chat models are created with base_model overrides only. Whole-catalog faults + // fail closed in parseModels before any file is written or deleted. + translateModel(entry, context) { + if (!isCatalogChatModel(entry)) return undefined; + const id = entry.model_name; + const info = entry.model_info; + const existing = context.existing(id); + // Existing entries must survive incomplete source data — a transient null + // price or an unresolved alias would otherwise delete the hand-authored + // TOML on the next run. They keep their authored base_model and cost/limit; + // only brand-new models need a fully-priced, resolvable source entry. + const baseModel = existing?.base_model ?? resolveBaseModel(id, info.huggingface_id ?? undefined); + const cost = info.input_cost_per_1m_tokens != null && info.output_cost_per_1m_tokens != null + ? { + input: info.input_cost_per_1m_tokens, + output: info.output_cost_per_1m_tokens, + cache_read: info.cache_read_input_cost_per_1m_tokens ?? undefined, + } + : existing?.cost; + const limit = info.max_input_tokens != null ? { context: info.max_input_tokens } : existing?.limit; + if (existing === undefined && (baseModel === undefined || cost === undefined || limit === undefined)) return undefined; + // Fail closed rather than emitting no reasoning_options: a reasoner with + // neither advertised efforts nor authored options would sync as an empty + // entry (no caller control). The runner keeps the file and lists it in the + // skipped notice so the options can be hand-authored. + const isReasoner = baseModel !== undefined + ? modelMetadata(baseModel).reasoning === true + : existing?.reasoning === true; + if (isReasoner && (info.reasoning_efforts ?? []).length === 0 && existing?.reasoning_options === undefined) { + throw new MissingReasoningOptionsError( + id, + `${id} is a reasoning model, but Nebul advertises no reasoning_efforts and the catalog entry has no reasoning_options; hand-author them`, + ); + } + const values = { + interleaved: existing?.interleaved, + reasoning_options: buildReasoningOptions(entry, existing), + cost, + limit, + }; + if (baseModel !== undefined) { + return { + id, + model: factorBaseModel(baseModel, values, limit) as SyncedModel, + }; + } + // Existing standalone definition whose served alias no longer resolves: + // keep the authored fields, refreshing only what /model/info still provides. + return { id, model: { ...existing, ...values } as SyncedModel }; + }, + // Only report chat models whose base_model could not be resolved; filtered + // serving artifacts (embeddings, rerankers, the ping model) skip silently. + sourceID(entry: NebulEntry) { + return isCatalogChatModel(entry) ? entry.model_name : undefined; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `Nebul models could not be resolved to lab metadata and need hand-authored base_model targets:`, + ids.map((id) => `\`${id}\``).join(", "), + ]; + }, +} satisfies SyncProvider; + +function isCatalogChatModel(entry: NebulEntry): boolean { + const info = entry.model_info; + return info.model_type === "llm" && info.mode === "chat" + && entry.model_name !== PING_MODEL && !DEPRECATED.has(entry.model_name) && !DENYLIST.test(entry.model_name); +} + +// Nebul documents exactly one reasoning control: reasoning_effort. When the +// host advertises efforts, write that effort entry and nothing else — +// lab-style toggles or budgets are not supported on this API. When it +// advertises none, keep the authored options; a reasoner with neither is +// rejected above so no empty options entry is ever synced. +function buildReasoningOptions(entry: NebulEntry, existing: ExistingModel | undefined) { + const efforts = entry.model_info.reasoning_efforts ?? []; + if (efforts.length === 0) return existing?.reasoning_options; + return [{ type: "effort" as const, values: efforts }]; +} + +function resolveBaseModel(servedID: string, huggingfaceID: string | undefined): string | undefined { + return baseModelCandidates(servedID, huggingfaceID).find(canonicalExists); +} + +// existsSync is case-insensitive on Windows/macOS; verify the real on-disk filename case +// so the resolved base_model matches the canonical metadata exactly (and CI on Linux). +function canonicalExists(candidate: string): boolean { + const file = path.join(MODELS_DIR, `${candidate}.toml`); + if (!existsSync(file)) return false; + try { + return readdirSync(path.dirname(file)).includes(path.basename(file)); + } catch { + return false; + } +} + +function baseModelCandidates(servedID: string, huggingfaceID: string | undefined): string[] { + const alias = BASE_MODEL_ALIASES[servedID]; + const servedCandidate = mapOrgToCandidate(servedID); + const hfCandidate = huggingfaceID === undefined ? undefined : mapOrgToCandidate(huggingfaceID); + return [ + ...new Set([alias, servedCandidate, hfCandidate, ...quantizationStripped(hfCandidate), ...quantizationStripped(servedCandidate)]).values(), + ].filter((candidate): candidate is string => candidate !== undefined); +} + +function mapOrgToCandidate(id: string): string | undefined { + const [org, ...modelParts] = id.split("/"); + if (org === undefined || modelParts.length === 0) return undefined; + const provider = ORG_TO_MODEL_PROVIDER[org]; + if (provider === undefined) return undefined; + return `${provider}/${modelParts.join("/").toLowerCase()}`; +} + +// Hosts serve quantized checkpoints (e.g. -FP8, -BF16) of weights whose canonical +// metadata is published for the base precision; try those names without the suffix. +// NVIDIA also prefixes checkpoints with "NVIDIA-", which the metadata names drop. +function quantizationStripped(candidate: string | undefined): string[] { + if (candidate === undefined) return []; + const withoutQuant = candidate.replace(/-(fp8|bf16|fp4|int8)$/i, ""); + const withoutPrefix = withoutQuant.replace(/nvidia-/, ""); + return withoutQuant === candidate ? [] : [...new Set([withoutQuant, withoutPrefix])].filter((value) => value !== candidate); +} diff --git a/packages/core/test/nebul.test.ts b/packages/core/test/nebul.test.ts new file mode 100644 index 00000000000..44610463e72 --- /dev/null +++ b/packages/core/test/nebul.test.ts @@ -0,0 +1,196 @@ +import { expect, test } from "bun:test"; + +import type { ExistingModel } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; +import { + NebulEntry, + NebulResponse, + nebul, +} from "../src/sync/providers/nebul.js"; + +function nebulEntry(model_name?: string, model_info: Record = {}): NebulEntry { + return NebulEntry.parse({ + model_name: model_name ?? "zai-org/GLM-5.3", + model_info: { + description: "test", + huggingface_id: "zai-org/GLM-5.3", + input_cost_per_1m_tokens: 1.47, + output_cost_per_1m_tokens: 4.62, + cache_read_input_cost_per_1m_tokens: 0.35, + max_input_tokens: 1_000_000, + mode: "chat", + model_type: "llm", + reasoning_efforts: ["low", "high", "max"], + ...model_info, + }, + }); +} + +function existingWith(reasoning_options: ExistingModel["reasoning_options"]): ExistingModel { + return { reasoning_options } as ExistingModel; +} + +const context = (existing: ExistingModel | undefined) => ({ existing: () => existing }); + +test("syncs Nebul's factored overrides against resolved lab metadata", () => { + const translated = nebul.translateModel(nebulEntry("zai-org/GLM-5.3", { max_input_tokens: 1_048_576 }), context(undefined)); + expect(translated).toMatchObject({ + id: "zai-org/GLM-5.3", + model: { + base_model: "zhipuai/glm-5.3", + cost: { input: 1.47, output: 4.62, cache_read: 0.35 }, + limit: { context: 1_048_576 }, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, + }); +}); + +test("preserves authored reasoning controls when the host exposes no efforts", () => { + const authored = [{ type: "toggle" as const }]; + const translated = nebul.translateModel(nebulEntry("zai-org/GLM-5.3", { reasoning_efforts: [] }), context(existingWith(authored))); + expect(translated?.model.reasoning_options).toEqual(authored); +}); + +test("replaces authored options with the advertised effort entry when efforts are advertised", () => { + const authored = [ + { type: "toggle" as const }, + { type: "budget_tokens" as const }, + { type: "effort" as const, values: ["low"] }, + ]; + const translated = nebul.translateModel(nebulEntry("zai-org/GLM-5.3"), context(existingWith(authored))); + expect(translated?.model.reasoning_options).toEqual([{ type: "effort", values: ["low", "high", "max"] }]); +}); + +test("carries authored interleaved through sync", () => { + const inline = nebul.translateModel(nebulEntry("zai-org/GLM-5.3"), context({ interleaved: true } as ExistingModel)); + expect(inline?.model.interleaved).toBe(true); + + const named = nebul.translateModel( + nebulEntry("deepseek-ai/DeepSeek-V4.1-Flash"), + context({ interleaved: { field: "reasoning_content" } } as ExistingModel), + ); + expect(named?.model.interleaved).toEqual({ field: "reasoning_content" }); +}); + +test("keeps authored effort sets when the host advertises none", () => { + const authored = [{ type: "effort" as const, values: ["low", "high", "max"] }]; + const translated = nebul.translateModel( + nebulEntry("moonshotai/Kimi-K3", { reasoning_efforts: [] }), + context(existingWith(authored)), + ); + expect(translated?.model.reasoning_options).toEqual(authored); +}); + +test("fails closed when a reasoner advertises no efforts and none are authored", () => { + const entry = nebulEntry("zai-org/GLM-5.3", { reasoning_efforts: [] }); + expect(() => nebul.translateModel(entry, context(undefined))).toThrow(MissingReasoningOptionsError); + expect(() => + nebul.translateModel(entry, context({ base_model: "zhipuai/glm-5.3" } as ExistingModel)), + ).toThrow(MissingReasoningOptionsError); +}); + +test("keeps existing entries when the source pricing or context is temporarily null", () => { + const existing = { + base_model: "zhipuai/glm-5.3", + cost: { input: 1.47, output: 4.62 }, + limit: { context: 1_048_576 }, + } as ExistingModel; + const translated = nebul.translateModel( + nebulEntry("zai-org/GLM-5.3", { input_cost_per_1m_tokens: null, output_cost_per_1m_tokens: null, max_input_tokens: null }), + context(existing), + ); + expect(translated).toMatchObject({ + id: "zai-org/GLM-5.3", + model: { base_model: "zhipuai/glm-5.3", cost: { input: 1.47, output: 4.62 }, limit: { context: 1_048_576 } }, + }); +}); + +test("keeps existing entries when the served alias no longer resolves to lab metadata", () => { + const existing = { + base_model: "zhipuai/glm-5.3", + cost: { input: 1.47, output: 4.62 }, + limit: { context: 1_048_576 }, + } as ExistingModel; + const translated = nebul.translateModel(nebulEntry("someorg/Unknown-Model", { huggingface_id: null }), context(existing)); + expect(translated?.model.base_model).toBe("zhipuai/glm-5.3"); +}); + +test("resolves base models across org renames and quantization suffixes", () => { + const cases: [string, string | null, string][] = [ + ["Qwen/Qwen3.8-27B-FP8", "Qwen/Qwen3.8-27B-FP8", "alibaba/qwen3.8-27b"], + ["nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", "nvidia/nemotron-3-nano-30b-a3b"], + ["mistralai/Mistral-Large-3-675B-Instruct-2512", "mistralai/Mistral-Large-3-675B-Instruct-2512", "mistral/mistral-large-2512"], + ["mistralai/Mistral-Medium-3.5-128B", "mistralai/Mistral-Medium-3.5-128B", "mistral/mistral-medium-2604"], + ]; + for (const [model_name, huggingface_id, expected] of cases) { + const entry = nebulEntry(model_name, { huggingface_id }); + expect(nebul.translateModel(entry, context(undefined))?.model.base_model).toBe(expected); + } +}); + +test("skips the ping model, serving artifacts, and deprecated entries silently", () => { + for (const model_name of ["Nebul/Ping", "zai-org/GLM-5.1-FP8", "zai-org/GLM-5.2-FP8", "Nebul-OCR/Some-OCR", "Qwen/Qwen3Guard-Something"]) { + const entry = nebulEntry(model_name, {}); + expect(nebul.translateModel(entry, context(undefined))).toBeUndefined(); + expect(nebul.sourceID(entry)).toBeUndefined(); + } +}); + +test("skips embeddings and rerankers while reporting unresolvable chat models", () => { + const embedding = nebulEntry("BAAI/bge-m3", { model_type: "embedding" }); + expect(nebul.translateModel(embedding, context(undefined))).toBeUndefined(); + expect(nebul.sourceID(embedding)).toBeUndefined(); + + const chat = nebulEntry("mistralai/Mistral-Large-3-675B-Instruct-2512", { huggingface_id: null }); + expect(nebul.translateModel(chat, context(undefined))).toBeDefined(); + expect(nebul.sourceID(chat)).toBe("mistralai/Mistral-Large-3-675B-Instruct-2512"); +}); + +test("skips chat models whose pricing or context is absent instead of crashing", () => { + const unpriced = nebulEntry("zai-org/GLM-5.3", { input_cost_per_1m_tokens: null, output_cost_per_1m_tokens: null, max_input_tokens: null }); + expect(nebul.translateModel(unpriced, context(undefined))).toBeUndefined(); + expect(nebul.sourceID(unpriced)).toBe("zai-org/GLM-5.3"); +}); + +test("parses nullable serving artifacts and unknown-host metadata from /model/info", () => { + const parsed = NebulResponse.parse({ + data: [ + { model_name: "Some/Embedding", model_info: { mode: null, model_type: "embedding" } }, + { model_name: "Some/Chat", model_info: { mode: "chat", model_type: "llm", unknown_host_field: true } }, + ], + }); + expect(parsed.data).toHaveLength(2); +}); + +test("fails closed on an empty catalog so sync cannot delete every local file", () => { + expect(() => nebul.parseModels({ data: [] })).toThrow("Nebul returned an empty model catalog"); +}); + +test("fails closed when no entry matches the chat-model filter", () => { + expect(() => + nebul.parseModels({ + data: [ + { model_name: "Some/Embedding", model_info: { mode: null, model_type: "embedding" } }, + { model_name: "Some/Reranker", model_info: { mode: null, model_type: "rerank" } }, + ], + }), + ).toThrow("Nebul returned no usable chat models"); +}); + +test("parseModels keeps chat entries alongside filtered serving artifacts", () => { + const parsed = nebul.parseModels({ + data: [ + { model_name: "Some/Embedding", model_info: { mode: null, model_type: "embedding" } }, + { model_name: "Some/Chat", model_info: { mode: "chat", model_type: "llm" } }, + ], + }); + expect(parsed).toHaveLength(2); +}); + +test("rejects unknown reasoning effort values from the host", () => { + expect(() => + NebulResponse.parse({ + data: [{ model_name: "Some/Chat", model_info: { mode: "chat", model_type: "llm", reasoning_efforts: ["ultra"] } }], + }), + ).toThrow(); +}); diff --git a/providers/nebul/logo.svg b/providers/nebul/logo.svg new file mode 100644 index 00000000000..33daff15792 --- /dev/null +++ b/providers/nebul/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/providers/nebul/models/Qwen/Qwen3-30B-A3B-Instruct-2507.toml b/providers/nebul/models/Qwen/Qwen3-30B-A3B-Instruct-2507.toml new file mode 100644 index 00000000000..2396ed198f9 --- /dev/null +++ b/providers/nebul/models/Qwen/Qwen3-30B-A3B-Instruct-2507.toml @@ -0,0 +1,6 @@ +base_model = "alibaba/qwen3-30b-a3b-instruct-2507" + +[cost] +input = 0.21 +output = 0.74 +cache_read = 0.05 diff --git a/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml b/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml new file mode 100644 index 00000000000..07d1d5954e0 --- /dev/null +++ b/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml @@ -0,0 +1,17 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content — that page names Qwen3 thinking variants explicitly. +base_model = "alibaba/qwen3.5-397b-a17b" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.63 +output = 3.78 +cache_read = 0.15 diff --git a/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml b/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml new file mode 100644 index 00000000000..f00d0f1fcd5 --- /dev/null +++ b/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml @@ -0,0 +1,17 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content — that page names Qwen3 thinking variants explicitly. +base_model = "alibaba/qwen3.8-27b" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.21 +output = 0.74 +cache_read = 0.05 diff --git a/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml b/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml new file mode 100644 index 00000000000..54b41c88bea --- /dev/null +++ b/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml @@ -0,0 +1,19 @@ +# Efforts: reasoning_effort = low|high|max (advertised by /model/info reasoning_efforts). +# reasoning_effort is Nebul's only documented reasoning control; no separate on/off toggle. +# https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +# Trace field: reasoning_content per that same page (Nebul's channel for OpenAI-style reasoners). +base_model = "deepseek/deepseek-v4.1-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[cost] +input = 0.2 +output = 0.7 + +[limit] +context = 1_048_576 diff --git a/providers/nebul/models/google/gemma-4-31B-it.toml b/providers/nebul/models/google/gemma-4-31B-it.toml new file mode 100644 index 00000000000..780dcf4dccd --- /dev/null +++ b/providers/nebul/models/google/gemma-4-31B-it.toml @@ -0,0 +1,16 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Traces arrive inline as inside the content, so interleaved = true +# https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "google/gemma-4-31b-it" +reasoning_options = [] + +interleaved = true + +[cost] +input = 0.42 +output = 1.05 diff --git a/providers/nebul/models/meta-models/muse-glimmer-30b.toml b/providers/nebul/models/meta-models/muse-glimmer-30b.toml new file mode 100644 index 00000000000..91a7725a533 --- /dev/null +++ b/providers/nebul/models/meta-models/muse-glimmer-30b.toml @@ -0,0 +1,18 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content per https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "meta/muse-glimmer-30b" + +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.7 +output = 3.5 +cache_read = 0.35 diff --git a/providers/nebul/models/mistralai/Ministral-3-14B-Instruct-2512.toml b/providers/nebul/models/mistralai/Ministral-3-14B-Instruct-2512.toml new file mode 100644 index 00000000000..d2768469116 --- /dev/null +++ b/providers/nebul/models/mistralai/Ministral-3-14B-Instruct-2512.toml @@ -0,0 +1,5 @@ +base_model = "mistral/ministral-3-14b-instruct-2512" + +[cost] +input = 0.73 +output = 3.63 diff --git a/providers/nebul/models/mistralai/Mistral-Large-3-675B-Instruct-2512.toml b/providers/nebul/models/mistralai/Mistral-Large-3-675B-Instruct-2512.toml new file mode 100644 index 00000000000..992a53758fd --- /dev/null +++ b/providers/nebul/models/mistralai/Mistral-Large-3-675B-Instruct-2512.toml @@ -0,0 +1,6 @@ +base_model = "mistral/mistral-large-2512" + +[cost] +input = 0.6 +output = 1.73 +cache_read = 0.15 diff --git a/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml b/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml new file mode 100644 index 00000000000..027d87b6fba --- /dev/null +++ b/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml @@ -0,0 +1,16 @@ +# Efforts: reasoning_effort = low|medium|high|max — exactly /model/info's advertised +# reasoning_efforts for this model, superseding the lab's none|high set. +# https://api.inference.nebul.io/model/info +# Trace field: reasoning_content per https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "mistral/mistral-medium-2604" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "max"] + +[cost] +input = 1.65 +output = 8.25 diff --git a/providers/nebul/models/moonshotai/Kimi-K3.toml b/providers/nebul/models/moonshotai/Kimi-K3.toml new file mode 100644 index 00000000000..2c90aaf6ce6 --- /dev/null +++ b/providers/nebul/models/moonshotai/Kimi-K3.toml @@ -0,0 +1,19 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model — the live response also marks this served ID with +# supports_reasoning = false — and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content per https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "moonshotai/kimi-k3" + +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 4.73 +output = 23.63 +cache_read = 1.13 diff --git a/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml new file mode 100644 index 00000000000..66a123d5534 --- /dev/null +++ b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml @@ -0,0 +1,17 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content — that page names the Nemotron family explicitly. +base_model = "nvidia/nemotron-3-nano-30b-a3b" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 1 +output = 3 +cache_read = 0.5 diff --git a/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.toml b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.toml new file mode 100644 index 00000000000..ea46772ad61 --- /dev/null +++ b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.toml @@ -0,0 +1,20 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content — that page names the Nemotron family explicitly. +base_model = "nvidia/nemotron-3-super-120b-a12b" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.32 +output = 0.69 +cache_read = 0.08 + +[limit] +context = 1_000_000 diff --git a/providers/nebul/models/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml b/providers/nebul/models/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml new file mode 100644 index 00000000000..3f6caaa49df --- /dev/null +++ b/providers/nebul/models/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml @@ -0,0 +1,20 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). It +# advertises none for this model, and the Chat Completions API documents no on/off +# toggle (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this +# host exposes no caller control here. +# Trace field: reasoning_content — that page names the Nemotron family explicitly. +base_model = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.16 +output = 1.05 +cache_read = 0.05 + +[limit] +context = 262_144 diff --git a/providers/nebul/models/openai/gpt-oss-120b.toml b/providers/nebul/models/openai/gpt-oss-120b.toml new file mode 100644 index 00000000000..c4abddea461 --- /dev/null +++ b/providers/nebul/models/openai/gpt-oss-120b.toml @@ -0,0 +1,16 @@ +# Efforts: reasoning_effort = low|medium|high — the model's documented Chat Completions control +# Trace field: reasoning_content for OpenAI-style models per +# https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "openai/gpt-oss-120b" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high"] + +[cost] +input = 0.17 +output = 0.63 +cache_read = 0.05 diff --git a/providers/nebul/models/zai-org/GLM-5.3-Flash.toml b/providers/nebul/models/zai-org/GLM-5.3-Flash.toml new file mode 100644 index 00000000000..f834a0f0b81 --- /dev/null +++ b/providers/nebul/models/zai-org/GLM-5.3-Flash.toml @@ -0,0 +1,20 @@ +# Efforts: reasoning_effort = low|high|max on POST /v1/chat/completions (verified live 2026-09-09) +# Traces arrive in message.reasoning (GLM-5.x family), so interleaved = true +# https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +# limit.context = 1_048_572 is the live GET /v1/model/info max_input_tokens for this +# exact ID (re-checked 2026-09-13); sibling GLM-5.3 is 1_048_576 — the 4-token +# difference between the two IDs is real, not a typo. +base_model = "zhipuai/glm-5.3-flash" + +interleaved = true + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[cost] +input = 0.21 +output = 0.63 + +[limit] +context = 1_048_572 diff --git a/providers/nebul/models/zai-org/GLM-5.3.toml b/providers/nebul/models/zai-org/GLM-5.3.toml new file mode 100644 index 00000000000..fe18dc8e5a6 --- /dev/null +++ b/providers/nebul/models/zai-org/GLM-5.3.toml @@ -0,0 +1,23 @@ +# Reasoning control per Nebul's docs: GET /v1/model/info `reasoning_efforts` is the +# per-model source of truth for "the reasoning_effort values each model meaningfully +# accepts" (https://docs.nebul.io/docs/inference-api/models/model-catalog). The live +# catalog (https://api.inference.nebul.io/model/info) advertises an empty list for +# this exact ID — unlike sibling zai-org/GLM-5.3-Flash, which advertises +# ["low","high","max"], so the differing values between the two IDs are intentional +# — and the Chat Completions API documents no on/off toggle +# (https://docs.nebul.io/docs/inference-api/models/chat-completions), so this host +# exposes no caller control here. +# Traces arrive in message.reasoning (GLM-5.x family), so interleaved = true +# https://docs.nebul.io/docs/inference-api/advanced-topics/reasoning +base_model = "zhipuai/glm-5.3" +reasoning_options = [] + +interleaved = true + +[cost] +input = 1.47 +output = 4.62 +cache_read = 0.35 + +[limit] +context = 1_048_576 diff --git a/providers/nebul/provider.toml b/providers/nebul/provider.toml new file mode 100644 index 00000000000..dc44e31d87d --- /dev/null +++ b/providers/nebul/provider.toml @@ -0,0 +1,5 @@ +name = "Nebul" +npm = "@ai-sdk/openai-compatible" +api = "https://api.inference.nebul.io/v1" +env = ["NEBUL_API_KEY"] +doc = "https://docs.nebul.io" diff --git a/sync.md b/sync.md index 06ceb1b2a42..acc43b5db69 100644 --- a/sync.md +++ b/sync.md @@ -279,6 +279,17 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - New documented models open deduped missing-model issues for manual authoring (`skipCreates`); local models absent from the docs are retained (`deleteMissing: false`). Image generation, transcription, and self-hosted models are outside this sync's scope. - Missing tables, unknown pricing tiers, invalid prices/limits, and duplicate model rows fail before writing. Documentation format changes require updating the parser, not guessing defaults. +## Nebul Notes + +Nebul is implemented in `packages/core/src/sync/providers/nebul.ts`. + +- Source endpoint: `https://api.inference.nebul.io/model/info` (the `/v1` alias also works). No authentication; with an API key the response would be project-scoped, so the sync intentionally calls it unauthenticated for the full public catalog. +- The endpoint is treated as the authoritative catalog: entries removed server-side are removed locally, and new resolvable chat models are created with `base_model` overrides only. +- Whole-catalog faults fail closed: an empty response, or one where nothing matches the chat-model filter (`model_type: "llm"`, `mode: "chat"`), throws in `parseModels` before any file is written or deleted — mirroring the LLM Gateway guards. +- Per-model robustness is the inverse: an existing entry survives transient null pricing, a missing context limit, or a served alias that no longer resolves to lab metadata, keeping its authored `base_model`/`cost`/`limit`; only brand-new models require a fully-priced, resolvable source entry. Embeddings, rerankers, OCR/guard models, the ping health check, and server-side-superseded GLM-5.1/5.2 IDs skip silently. +- `reasoning_options` come from the endpoint's per-model `reasoning_efforts` list, Nebul's only documented reasoning control. A reasoner with neither advertised efforts nor authored controls fails sync for manual authoring rather than writing an empty control set. +- `interleaved`, `status`, and other fields the endpoint does not expose are preserved from existing files; the reasoning trace channel (`reasoning_content` vs `message.reasoning` vs inline ``) is family-specific per Nebul's docs. + ## OVHcloud Notes OVHcloud AI Endpoints is implemented in `packages/core/src/sync/providers/ovhcloud.ts`.