From 677312f5bfad95cb6e2dfe339b0aa7a7dc41df64 Mon Sep 17 00:00:00 2001 From: Wynand Huizinga Date: Sat, 12 Sep 2026 13:47:45 +0200 Subject: [PATCH 1/4] feat(nebul): add Nebul provider with 16 models and hourly sync module Adds the Nebul inference platform (https://nebul.io) as a provider: - provider entry (npm @ai-sdk/openai-compatible, env NEBUL_API_KEY, base https://api.inference.nebul.io/v1) - 16 served chat models with live costs/limits and per-model reasoning controls; effort values only where the live catalog (https://api.inference.nebul.io/model/info) "reasoning_efforts" or Nebul docs establish accepted values (DeepSeek V4.1 Flash, GLM-5.3 Flash, Mistral Medium 3.5, gpt-oss-120b), empty otherwise; no toggles or budgets (undocumented) - missing lab metadata for models/alibaba/qwen3-30b-a3b-instruct-2507 - an hourly sync module with delete safety, base_model alias autodiscovery, and fail-closed behavior for undocumented reasoning controls Supersedes #6876. Closes #6879. --- .../alibaba/qwen3-30b-a3b-instruct-2507.toml | 28 +++ packages/core/src/sync/index.ts | 5 +- packages/core/src/sync/providers/nebul.ts | 200 ++++++++++++++++++ packages/core/test/nebul.test.ts | 171 +++++++++++++++ packages/sdk/src/generated.ts | 3 + providers/nebul/logo.svg | 5 + .../Qwen/Qwen3-30B-A3B-Instruct-2507.toml | 6 + .../nebul/models/Qwen/Qwen3.5-397B-A17B.toml | 16 ++ .../nebul/models/Qwen/Qwen3.8-27B-FP8.toml | 16 ++ .../deepseek-ai/DeepSeek-V4.1-Flash.toml | 18 ++ .../nebul/models/google/gemma-4-31B-it.toml | 15 ++ .../models/meta-models/muse-glimmer-30b.toml | 17 ++ .../Ministral-3-14B-Instruct-2512.toml | 5 + .../Mistral-Large-3-675B-Instruct-2512.toml | 6 + .../mistralai/Mistral-Medium-3.5-128B.toml | 15 ++ .../nebul/models/moonshotai/Kimi-K3.toml | 18 ++ .../NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml | 16 ++ ...VIDIA-Nemotron-3-Super-120B-A12B-BF16.toml | 19 ++ ...ron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml | 19 ++ .../nebul/models/openai/gpt-oss-120b.toml | 14 ++ .../nebul/models/zai-org/GLM-5.3-Flash.toml | 16 ++ providers/nebul/models/zai-org/GLM-5.3.toml | 22 ++ providers/nebul/provider.toml | 5 + 23 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 models/alibaba/qwen3-30b-a3b-instruct-2507.toml create mode 100644 packages/core/src/sync/providers/nebul.ts create mode 100644 packages/core/test/nebul.test.ts create mode 100644 providers/nebul/logo.svg create mode 100644 providers/nebul/models/Qwen/Qwen3-30B-A3B-Instruct-2507.toml create mode 100644 providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml create mode 100644 providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml create mode 100644 providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml create mode 100644 providers/nebul/models/google/gemma-4-31B-it.toml create mode 100644 providers/nebul/models/meta-models/muse-glimmer-30b.toml create mode 100644 providers/nebul/models/mistralai/Ministral-3-14B-Instruct-2512.toml create mode 100644 providers/nebul/models/mistralai/Mistral-Large-3-675B-Instruct-2512.toml create mode 100644 providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml create mode 100644 providers/nebul/models/moonshotai/Kimi-K3.toml create mode 100644 providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml create mode 100644 providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.toml create mode 100644 providers/nebul/models/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml create mode 100644 providers/nebul/models/openai/gpt-oss-120b.toml create mode 100644 providers/nebul/models/zai-org/GLM-5.3-Flash.toml create mode 100644 providers/nebul/models/zai-org/GLM-5.3.toml create mode 100644 providers/nebul/provider.toml 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 7f88aacb5bb..22a37b91b10 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 { openai } from "./providers/openai.js"; import { ofox } from "./providers/ofox.js"; @@ -152,6 +153,7 @@ export const providers: { "llmgateway-providers": SyncProvider; "merge-gateway": SyncProvider; meta: SyncProvider; + nebul: SyncProvider; "nano-gpt": SyncProvider; ofox: SyncProvider; openai: SyncProvider; @@ -187,6 +189,7 @@ export const providers: { "llmgateway-providers": llmgatewayProviders, "merge-gateway": mergeGateway, meta, + nebul, "nano-gpt": nanoGpt, ofox, openai, @@ -219,7 +222,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", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "github-copilot", "google", "hyper", "meta", "nebul", "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..51bac2061dc --- /dev/null +++ b/packages/core/src/sync/providers/nebul.ts @@ -0,0 +1,200 @@ +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) { + return NebulResponse.parse(raw).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. + 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..cced07fce57 --- /dev/null +++ b/packages/core/test/nebul.test.ts @@ -0,0 +1,171 @@ +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("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/packages/sdk/src/generated.ts b/packages/sdk/src/generated.ts index a76f96b4daf..b52e7d9522d 100644 --- a/packages/sdk/src/generated.ts +++ b/packages/sdk/src/generated.ts @@ -59,6 +59,7 @@ export type ModelFamily = | "glm-z" | "glmv" | "gpt" + | "gpt-astra" | "gpt-codex" | "gpt-codex-mini" | "gpt-codex-spark" @@ -138,6 +139,7 @@ export type ModelFamily = | "model-router" | "morph" | "muse" + | "muse-free" | "nano-banana" | "nemoretriever" | "nemotron" @@ -184,6 +186,7 @@ export type ModelFamily = | "ring-1t-free" | "rnj" | "runway" + | "sakana-namazu" | "sarvam" | "seed" | "sherlock" 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..009e54c19fd --- /dev/null +++ b/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.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. +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..e8efa43fbf8 --- /dev/null +++ b/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.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. +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..c39e6cb1602 --- /dev/null +++ b/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml @@ -0,0 +1,18 @@ +# 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 +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..5accf868656 --- /dev/null +++ b/providers/nebul/models/google/gemma-4-31B-it.toml @@ -0,0 +1,15 @@ +# 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 (Nebul docs), so interleaved = true +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..4310f2dc8a5 --- /dev/null +++ b/providers/nebul/models/meta-models/muse-glimmer-30b.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. +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..428929c4918 --- /dev/null +++ b/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml @@ -0,0 +1,15 @@ +# 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 +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..c28ff87c543 --- /dev/null +++ b/providers/nebul/models/moonshotai/Kimi-K3.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 — 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. +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..0b45ea1bb92 --- /dev/null +++ b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.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. +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..e8bd3c054dc --- /dev/null +++ b/providers/nebul/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.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, 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. +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..943ff62c877 --- /dev/null +++ b/providers/nebul/models/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.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, 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. +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..7e53cb3b259 --- /dev/null +++ b/providers/nebul/models/openai/gpt-oss-120b.toml @@ -0,0 +1,14 @@ +# Efforts: reasoning_effort = low|medium|high — the model's documented Chat Completions control +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..892df7195e5 --- /dev/null +++ b/providers/nebul/models/zai-org/GLM-5.3-Flash.toml @@ -0,0 +1,16 @@ +# Efforts: reasoning_effort = low|high|max on POST /v1/chat/completions (verified live 2026-09-09) +# Traces arrive in message.reasoning (Nebul docs), so interleaved = true +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..2d8ffc7255e --- /dev/null +++ b/providers/nebul/models/zai-org/GLM-5.3.toml @@ -0,0 +1,22 @@ +# 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 (Nebul docs), so interleaved = true +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" From 61e2b05f5d45c0de2df038baf462905689fe73b3 Mon Sep 17 00:00:00 2001 From: Wynand Huizinga Date: Sat, 12 Sep 2026 13:47:53 +0200 Subject: [PATCH 2/4] chore: revert incidental packages/sdk build artifact from commit --- packages/sdk/src/generated.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/sdk/src/generated.ts b/packages/sdk/src/generated.ts index b52e7d9522d..a76f96b4daf 100644 --- a/packages/sdk/src/generated.ts +++ b/packages/sdk/src/generated.ts @@ -59,7 +59,6 @@ export type ModelFamily = | "glm-z" | "glmv" | "gpt" - | "gpt-astra" | "gpt-codex" | "gpt-codex-mini" | "gpt-codex-spark" @@ -139,7 +138,6 @@ export type ModelFamily = | "model-router" | "morph" | "muse" - | "muse-free" | "nano-banana" | "nemoretriever" | "nemotron" @@ -186,7 +184,6 @@ export type ModelFamily = | "ring-1t-free" | "rnj" | "runway" - | "sakana-namazu" | "sarvam" | "seed" | "sherlock" From a502ffc7d359b9571da3c9bed707aff6aadcc618 Mon Sep 17 00:00:00 2001 From: Wynand Huizinga Date: Sun, 13 Sep 2026 21:43:18 +0200 Subject: [PATCH 3/4] docs(nebul): cite reasoning trace-field source; annotate GLM-5.3-Flash context - Each reasoner cites Nebul's Reasoning docs page for why its trace channel is reasoning_content, message.reasoning, or inline (field is family-specific by design, not an inconsistency) - GLM-5.3-Flash: note 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 --- providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml | 1 + providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml | 1 + providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml | 1 + providers/nebul/models/google/gemma-4-31B-it.toml | 3 ++- providers/nebul/models/meta-models/muse-glimmer-30b.toml | 1 + .../nebul/models/mistralai/Mistral-Medium-3.5-128B.toml | 1 + providers/nebul/models/moonshotai/Kimi-K3.toml | 1 + .../models/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16.toml | 1 + .../nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16.toml | 1 + .../nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8.toml | 1 + providers/nebul/models/openai/gpt-oss-120b.toml | 2 ++ providers/nebul/models/zai-org/GLM-5.3-Flash.toml | 6 +++++- providers/nebul/models/zai-org/GLM-5.3.toml | 3 ++- 13 files changed, 20 insertions(+), 3 deletions(-) diff --git a/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml b/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml index 009e54c19fd..07d1d5954e0 100644 --- a/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml +++ b/providers/nebul/models/Qwen/Qwen3.5-397B-A17B.toml @@ -4,6 +4,7 @@ # 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 = [] diff --git a/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml b/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml index e8efa43fbf8..f00d0f1fcd5 100644 --- a/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml +++ b/providers/nebul/models/Qwen/Qwen3.8-27B-FP8.toml @@ -4,6 +4,7 @@ # 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 = [] diff --git a/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml b/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml index c39e6cb1602..54b41c88bea 100644 --- a/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml +++ b/providers/nebul/models/deepseek-ai/DeepSeek-V4.1-Flash.toml @@ -1,6 +1,7 @@ # 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] diff --git a/providers/nebul/models/google/gemma-4-31B-it.toml b/providers/nebul/models/google/gemma-4-31B-it.toml index 5accf868656..780dcf4dccd 100644 --- a/providers/nebul/models/google/gemma-4-31B-it.toml +++ b/providers/nebul/models/google/gemma-4-31B-it.toml @@ -4,7 +4,8 @@ # 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 (Nebul docs), so interleaved = true +# 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 = [] diff --git a/providers/nebul/models/meta-models/muse-glimmer-30b.toml b/providers/nebul/models/meta-models/muse-glimmer-30b.toml index 4310f2dc8a5..91a7725a533 100644 --- a/providers/nebul/models/meta-models/muse-glimmer-30b.toml +++ b/providers/nebul/models/meta-models/muse-glimmer-30b.toml @@ -4,6 +4,7 @@ # 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 = [] diff --git a/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml b/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml index 428929c4918..027d87b6fba 100644 --- a/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml +++ b/providers/nebul/models/mistralai/Mistral-Medium-3.5-128B.toml @@ -1,6 +1,7 @@ # 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] diff --git a/providers/nebul/models/moonshotai/Kimi-K3.toml b/providers/nebul/models/moonshotai/Kimi-K3.toml index c28ff87c543..2c90aaf6ce6 100644 --- a/providers/nebul/models/moonshotai/Kimi-K3.toml +++ b/providers/nebul/models/moonshotai/Kimi-K3.toml @@ -5,6 +5,7 @@ # 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 = [] 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 index 0b45ea1bb92..66a123d5534 100644 --- 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 @@ -4,6 +4,7 @@ # 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 = [] 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 index e8bd3c054dc..ea46772ad61 100644 --- 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 @@ -4,6 +4,7 @@ # 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 = [] 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 index 943ff62c877..3f6caaa49df 100644 --- 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 @@ -4,6 +4,7 @@ # 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 = [] diff --git a/providers/nebul/models/openai/gpt-oss-120b.toml b/providers/nebul/models/openai/gpt-oss-120b.toml index 7e53cb3b259..c4abddea461 100644 --- a/providers/nebul/models/openai/gpt-oss-120b.toml +++ b/providers/nebul/models/openai/gpt-oss-120b.toml @@ -1,4 +1,6 @@ # 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] diff --git a/providers/nebul/models/zai-org/GLM-5.3-Flash.toml b/providers/nebul/models/zai-org/GLM-5.3-Flash.toml index 892df7195e5..f834a0f0b81 100644 --- a/providers/nebul/models/zai-org/GLM-5.3-Flash.toml +++ b/providers/nebul/models/zai-org/GLM-5.3-Flash.toml @@ -1,5 +1,9 @@ # Efforts: reasoning_effort = low|high|max on POST /v1/chat/completions (verified live 2026-09-09) -# Traces arrive in message.reasoning (Nebul docs), so interleaved = true +# 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 diff --git a/providers/nebul/models/zai-org/GLM-5.3.toml b/providers/nebul/models/zai-org/GLM-5.3.toml index 2d8ffc7255e..fe18dc8e5a6 100644 --- a/providers/nebul/models/zai-org/GLM-5.3.toml +++ b/providers/nebul/models/zai-org/GLM-5.3.toml @@ -7,7 +7,8 @@ # — 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 (Nebul docs), so interleaved = true +# 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 = [] From 98240766182f046dcce9c2c681a68fb2d2b88354 Mon Sep 17 00:00:00 2001 From: Wynand Huizinga Date: Mon, 14 Sep 2026 08:45:44 +0200 Subject: [PATCH 4/4] fix(sync): fail closed on empty/undecodable Nebul catalogs deleteMissing defaults on for Nebul, so an empty or shape-drifted /model/info response would sync as an empty desired set and delete every local model file. Guard in parseModels (llmgateway-style): throw when the catalog is empty or contains no usable chat models, before the runner writes or deletes anything. Whole-catalog faults abort; per-model transient nulls and unresolvable aliases still retain existing entries. Document the behavior under Nebul Notes in sync.md. --- packages/core/src/sync/providers/nebul.ts | 16 +++++++++++++-- packages/core/test/nebul.test.ts | 25 +++++++++++++++++++++++ sync.md | 11 ++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core/src/sync/providers/nebul.ts b/packages/core/src/sync/providers/nebul.ts index 51bac2061dc..b3010f0a5c1 100644 --- a/packages/core/src/sync/providers/nebul.ts +++ b/packages/core/src/sync/providers/nebul.ts @@ -72,11 +72,23 @@ export const nebul = { return response.json(); }, parseModels(raw) { - return NebulResponse.parse(raw).data; + 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. + // 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; diff --git a/packages/core/test/nebul.test.ts b/packages/core/test/nebul.test.ts index cced07fce57..44610463e72 100644 --- a/packages/core/test/nebul.test.ts +++ b/packages/core/test/nebul.test.ts @@ -162,6 +162,31 @@ test("parses nullable serving artifacts and unknown-host metadata from /model/in 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({ 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`.