Skip to content
Closed
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
28 changes: 28 additions & 0 deletions models/alibaba/qwen3-30b-a3b-instruct-2507.toml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 4 additions & 1 deletion packages/core/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -153,6 +154,7 @@ export const providers: {
"llmgateway-providers": SyncProvider<any>;
"merge-gateway": SyncProvider<any>;
meta: SyncProvider<any>;
nebul: SyncProvider<any>;
"nano-gpt": SyncProvider<any>;
ofox: SyncProvider<any>;
"ollama-cloud": SyncProvider<any>;
Expand Down Expand Up @@ -189,6 +191,7 @@ export const providers: {
"llmgateway-providers": llmgatewayProviders,
"merge-gateway": mergeGateway,
meta,
nebul,
"nano-gpt": nanoGpt,
ofox,
"ollama-cloud": ollamaCloud,
Expand Down Expand Up @@ -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;
Expand Down
212 changes: 212 additions & 0 deletions packages/core/src/sync/providers/nebul.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
"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<string, string | undefined> = {
"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<typeof NebulEntry>;

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<NebulEntry>;

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);
}
Loading
Loading