diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index f08532afa14..ec9e3f5d864 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -26,6 +26,7 @@ import { kilo } from "./providers/kilo.js"; import { llmgateway, llmgatewayProviders } from "./providers/llmgateway.js"; import { mergeGateway } from "./providers/merge-gateway.js"; import { nanoGpt } from "./providers/nano-gpt.js"; +import { neosantara } from "./providers/neosantara.js"; import { openai } from "./providers/openai.js"; import { ofox } from "./providers/ofox.js"; import { openrouter } from "./providers/openrouter.js"; @@ -150,6 +151,7 @@ export const providers: { "llmgateway-providers": SyncProvider; "merge-gateway": SyncProvider; "nano-gpt": SyncProvider; + neosantara: SyncProvider; ofox: SyncProvider; openai: SyncProvider; openrouter: SyncProvider; @@ -184,6 +186,7 @@ export const providers: { "llmgateway-providers": llmgatewayProviders, "merge-gateway": mergeGateway, "nano-gpt": nanoGpt, + neosantara, ofox, openai, openrouter, @@ -209,6 +212,7 @@ export const groups = { "llmgateway-providers", "merge-gateway", "nano-gpt", + "neosantara", "ofox", "requesty", "openrouter", diff --git a/packages/core/src/sync/providers/neosantara.ts b/packages/core/src/sync/providers/neosantara.ts new file mode 100644 index 00000000000..8c1fc5d7a59 --- /dev/null +++ b/packages/core/src/sync/providers/neosantara.ts @@ -0,0 +1,316 @@ +import { z } from "zod"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; + +// Neosantara serves a models.dev / LLM Gateway shaped catalog at this single public endpoint. +const CATALOG_ENDPOINT = "https://api.neosantara.xyz/v1/catalog"; +const MIN_CONTEXT_WINDOW = 100_000; + +// Accepted reasoning_effort enum; efforts from the catalog are intersected with it defensively. +const HOST_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +const HOST_EFFORT_SET: ReadonlySet = new Set(HOST_EFFORTS); + +const CatalogProvider = z + .object({ + providerId: z.string().optional(), + vision: z.boolean().optional(), + tools: z.boolean().optional(), + reasoning: z.boolean().optional(), + reasoning_efforts: z.array(z.string()).optional(), + has_toggle: z.boolean().optional(), + }) + .passthrough(); + +// Per-token USD strings; "0" is a real price for free models (kept), unknown for cache/reasoning. +const CatalogPricing = z + .object({ + prompt: z.string().optional(), + completion: z.string().optional(), + internal_reasoning: z.string().optional(), + input_cache_read: z.string().optional(), + input_cache_write: z.string().optional(), + }) + .passthrough(); + +export const NeosantaraModel = z + .object({ + id: z.string(), + name: z.string().optional(), + display_name: z.string().optional(), + created: z.number().optional(), + description: z.string().optional(), + family: z.string().optional(), + architecture: z.object({ + input_modalities: z.array(z.string()), + output_modalities: z.array(z.string()), + }), + context_length: z.number().int().nonnegative().optional(), + pricing: CatalogPricing, + supported_parameters: z.array(z.string()).optional(), + structured_outputs: z.boolean().optional(), + base_model: z.string().optional(), + override_name: z.string().optional(), + providers: z.array(CatalogProvider).min(1), + deprecated: z.boolean().optional(), + }) + .passthrough(); + +export const NeosantaraCatalogResponse = z + .object({ data: z.array(NeosantaraModel) }) + .passthrough(); + +export type NeosantaraSourceModel = z.infer; + +// One mapping per model carries this host's capability flags and reasoning efforts. +function mapping(model: NeosantaraSourceModel) { + return model.providers[0]; +} + +// Canonical lab base models are supplied directly by the /v1/catalog endpoint, verified +// against the local models/ directory before falling back to id-based resolution. +export function resolveNeosantaraBaseModel(model: NeosantaraSourceModel | string) { + if (typeof model === "string") return resolveModelMetadataBaseModel(model); + if (model.base_model !== undefined) { + const verified = resolveModelMetadataBaseModel(model.base_model); + if (verified !== undefined) return verified; + } + return resolveModelMetadataBaseModel(model.id); +} + +type ReasoningControls = NonNullable; + +// Toggle controls carry a leading wire comment naming the off value (AGENTS.md requirement). +const TOGGLE_HEADER = `# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +`; + +// Whether the catalog reported a caller-control surface for this reasoning model. A missing +// `reasoning_efforts` is "unknown" (skip + report), NOT an affirmative always-on `[]`. +function hasReasoningEfforts(model: NeosantaraSourceModel) { + return Array.isArray(mapping(model).reasoning_efforts); +} + +// The catalog reports each model's real reasoning surface (resolved host-side from the model's +// models.dev lab entry): [] = always-on (no caller control), ["none"] = on/off toggle, otherwise +// the graded effort levels. When off is expressed via reasoning_effort = "none" alongside graded +// levels, AGENTS.md strictly requires authoring only `effort` with `none` in values and NO toggle. +export function neosantaraReasoningControls( + model: NeosantaraSourceModel, + _existing?: ExistingModel, +): ReasoningControls { + const map = mapping(model); + const efforts = (map.reasoning_efforts ?? []).filter((effort) => + HOST_EFFORT_SET.has(effort), + ); + if (efforts.length === 0) return []; + if (efforts.length === 1 && efforts[0] === "none") return [{ type: "toggle" }]; + + const graded = efforts.filter((effort) => effort !== "none"); + const values = efforts.includes("none") ? ["none", ...graded] : graded; + return [{ type: "effort", values: values as never }]; +} + +// Toggle controls carry a leading wire comment naming the off value (AGENTS.md requirement). +// Dedicated headers document always-on relays (minimax-m2.7) and external effort baselines (muse-glimmer-30b). +export function neosantaraReasoningHeader( + controls: ReasoningControls | undefined, + modelId?: string, +) { + if (controls?.some((option) => option.type === "toggle")) { + return TOGGLE_HEADER; + } + if (modelId === "minimax-m2.7") { + return `# Always-on thinking: upstream Dahl forwards no reasoning_effort parameter. +# Matches lab providers/minimax/models/MiniMax-M2.7.toml and peers OpenRouter/FastRouter/Cortecs. +`; + } + if (modelId === "kimi-k2-thinking") { + return `# Dedicated thinking variant: always-on reasoning with no caller control. +# Matches lab providers/moonshotai/models/kimi-k2-thinking.toml and peers OpenRouter/Vercel. +`; + } + if (modelId === "muse-glimmer-30b") { + return `# Sources: +# https://huggingface.co/meta-models/Muse-Glimmer-30B +# Effort: reasoning_effort = low|medium|high|xhigh +# Matches peers OpenRouter and Vercel AI Gateway. +`; + } + return undefined; +} + +// Image models output an image modality, are priced per image, and skip the token filters. +function isImageModel(model: NeosantaraSourceModel) { + return model.architecture.output_modalities.includes("image"); +} + +// Per-token USD string -> USD per million tokens. `0` is preserved (free models are real). +function price(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const number = Number(value); + return Number.isFinite(number) && number >= 0 + ? Math.round(number * 1_000_000_000_000) / 1_000_000 + : undefined; +} + +// Cache/reasoning prices report `0` when unknown; never downgrade to a published zero. +function nonZeroPrice(value: string | undefined): number | undefined { + const result = price(value); + return result !== undefined && result > 0 ? result : undefined; +} + +const ALLOWED_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]); + +function modalities(values: string[]): string[] { + const mapped = values + .map((value) => value.toLowerCase()) + .map((value) => (value === "file" ? "pdf" : value)) + .filter((value) => ALLOWED_MODALITIES.has(value)); + return [...new Set(mapped.length > 0 ? mapped : ["text"])]; +} + +// Modalities as served by a specific deployment: a mapping without vision must +// not carry image/pdf/video input, regardless of what the model-level architecture +// claims — attachment=false with image/pdf/video input is contradictory. +export function deploymentModalities(model: NeosantaraSourceModel, vision: boolean | undefined) { + const baseInput = modalities(model.architecture.input_modalities); + if (vision !== false) { + return { + input: baseInput, + output: modalities(model.architecture.output_modalities), + }; + } + const filtered = baseInput.filter( + (value) => value !== "image" && value !== "pdf" && value !== "video", + ); + return { + input: filtered.length > 0 ? filtered : ["text"], + output: modalities(model.architecture.output_modalities), + }; +} + +// Image generators, or 100k+ context function-calling text models. +export function meetsNeosantaraPublicFilter(model: NeosantaraSourceModel) { + return ( + isImageModel(model) || + ((model.context_length ?? 0) >= MIN_CONTEXT_WINDOW && mapping(model).tools === true) + ); +} + +export function shouldSyncNeosantaraModel(model: NeosantaraSourceModel) { + if (model.deprecated || resolveNeosantaraBaseModel(model) === undefined) return false; + if (!meetsNeosantaraPublicFilter(model)) return false; + if (isImageModel(model)) return true; + // Skip rather than throw: one missing price costs a single model, not the run. + if (price(model.pricing.prompt) === undefined || price(model.pricing.completion) === undefined) { + return false; + } + // A reasoning model with no reported effort surface is unknown, not always-on: skip it (and + // report it) rather than stamping `[]`. Explicit `[]` from the catalog is a real always-on set. + if (mapping(model).reasoning === true && !hasReasoningEfforts(model)) return false; + return true; +} + +export function buildNeosantaraModel( + model: NeosantaraSourceModel, + existing: ExistingModel | undefined, +): SyncedModel { + const baseModel = resolveNeosantaraBaseModel(model); + if (baseModel === undefined) { + throw new Error(`No canonical base model mapping for Neosantara model '${model.id}'`); + } + + // context_length is 0 for image models; never author limit.context = 0 — inherit the base. + const limit = { context: model.context_length || undefined }; + const baseModelOmit = existing?.base_model === baseModel ? existing.base_model_omit : undefined; + + // Per-image pricing has no models.dev field, so cost is left unpublished and inherited. + if (isImageModel(model)) { + return factorBaseModel( + baseModel, + { status: model.deprecated ? "deprecated" : existing?.status }, + limit, + baseModelOmit, + ); + } + + const map = mapping(model); + const reasoning = map.reasoning === true; + const inputCost = price(model.pricing.prompt); + const outputCost = price(model.pricing.completion); + if (inputCost === undefined || outputCost === undefined) { + throw new Error(`Missing token pricing for Neosantara model '${model.id}'`); + } + + return factorBaseModel( + baseModel, + { + name: model.override_name, + reasoning, + reasoning_options: reasoning ? neosantaraReasoningControls(model, existing) : undefined, + interleaved: reasoning ? { field: "reasoning_content" as const } : undefined, + attachment: map.vision, + tool_call: map.tools, + structured_output: model.structured_outputs === false ? false : undefined, + modalities: deploymentModalities(model, map.vision), + status: model.deprecated ? "deprecated" : existing?.status, + limit, + cost: { + input: inputCost, + output: outputCost, + reasoning: reasoning ? nonZeroPrice(model.pricing.internal_reasoning) : undefined, + cache_read: nonZeroPrice(model.pricing.input_cache_read), + cache_write: nonZeroPrice(model.pricing.input_cache_write), + }, + }, + limit, + baseModelOmit, + ); +} + +async function fetchJson(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Neosantara request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export const neosantara = { + id: "neosantara", + name: "Neosantara", + modelsDir: "providers/neosantara/models", + authoritativeHeaders: true, + sourceID(model) { + if (model.deprecated) return undefined; + return meetsNeosantaraPublicFilter(model) ? model.id : undefined; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} Neosantara models were not created because they lack a canonical \`models/\` entry to inherit, the catalog reported no usable token pricing, or a reasoning model did not report its effort surface.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + "Ensure the catalog provides a valid base_model or add a `models//.toml` entry to include them in the next sync.", + ]; + }, + async fetchModels() { + return fetchJson(CATALOG_ENDPOINT); + }, + parseModels(raw) { + const data = NeosantaraCatalogResponse.parse(raw).data; + // An empty catalog would delete every model file; fail loudly instead of wiping the provider. + if (data.length === 0) { + throw new Error("Neosantara catalog returned no models"); + } + return data; + }, + translateModel(model, context) { + if (!shouldSyncNeosantaraModel(model)) return undefined; + const translated = buildNeosantaraModel(model, context.existing(model.id)); + return { + id: model.id, + model: translated, + header: neosantaraReasoningHeader(translated.reasoning_options, model.id), + }; + }, +} satisfies SyncProvider; diff --git a/packages/core/test/neosantara-sync.test.ts b/packages/core/test/neosantara-sync.test.ts new file mode 100644 index 00000000000..4aa5f4f6b43 --- /dev/null +++ b/packages/core/test/neosantara-sync.test.ts @@ -0,0 +1,398 @@ +import { expect, test } from "bun:test"; + +import { + buildNeosantaraModel, + deploymentModalities, + meetsNeosantaraPublicFilter, + neosantara, + neosantaraReasoningControls, + neosantaraReasoningHeader, + NeosantaraCatalogResponse, + resolveNeosantaraBaseModel, + shouldSyncNeosantaraModel, +} from "../src/sync/providers/neosantara.js"; + +// A /v1/catalog response in the models.dev / LLM Gateway shape the backend now emits. +const catalogResponse = { + data: [ + { + id: "gemini-3.7-flash", + name: "gemini-3.7-flash", + display_name: "Neosantara: Gemini 3.7 Flash", + created: 1_700_000_000, + description: "Fast reasoning model", + family: "google", + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + context_length: 1_000_000, + pricing: { + prompt: "0.000001500000", + completion: "0.000006000000", + internal_reasoning: "0", + input_cache_read: "0.000000150000", + input_cache_write: "0.000001500000", + }, + supported_parameters: ["temperature", "tools", "tool_choice", "response_format", "reasoning"], + structured_outputs: true, + providers: [ + { + providerId: "neosantara", + vision: true, + tools: true, + reasoning: true, + reasoning_efforts: ["none", "low", "medium", "high"], + }, + ], + deprecated: false, + }, + { + id: "gpt-oss-20b", + name: "gpt-oss-20b", + display_name: "Neosantara: Gpt Oss 20b", + created: 1_700_000_000, + description: "Open model, no reasoning on this host", + family: "openai", + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + context_length: 131_072, + pricing: { prompt: "0.000000020000", completion: "0.000000080000" }, + supported_parameters: ["temperature", "tools", "tool_choice"], + structured_outputs: true, + providers: [{ providerId: "neosantara", vision: false, tools: true, reasoning: false }], + deprecated: false, + }, + { + id: "glm-4.7-flash", + name: "glm-4.7-flash", + display_name: "Neosantara: Glm 4.7 Flash", + created: 1_700_000_000, + description: "Free reasoning model", + family: "zhipuai", + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + context_length: 1_000_000, + pricing: { prompt: "0.000000000000", completion: "0.000000000000" }, + supported_parameters: ["temperature", "tools", "reasoning"], + structured_outputs: false, + providers: [{ providerId: "neosantara", vision: false, tools: true, reasoning: true, reasoning_efforts: ["none"] }], + deprecated: false, + }, + { + id: "too-small", + name: "too-small", + display_name: "Neosantara: Too Small", + created: 1_700_000_000, + description: "Below the context floor", + family: "openai", + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + context_length: 32_000, + pricing: { prompt: "0.000001000000", completion: "0.000002000000" }, + supported_parameters: ["tools"], + structured_outputs: false, + providers: [{ providerId: "neosantara", vision: false, tools: true, reasoning: false }], + deprecated: false, + }, + { + id: "no-tools", + name: "no-tools", + display_name: "Neosantara: No Tools", + created: 1_700_000_000, + description: "No function calling", + family: "openai", + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + context_length: 128_000, + pricing: { prompt: "0.000001000000", completion: "0.000002000000" }, + supported_parameters: [], + structured_outputs: false, + providers: [{ providerId: "neosantara", vision: false, tools: false, reasoning: false }], + deprecated: false, + }, + ], +}; + +test("parses the single /v1/catalog endpoint", () => { + const parsed = NeosantaraCatalogResponse.parse(catalogResponse); + expect(parsed.data).toHaveLength(5); + expect(parsed.data[0]?.providers[0]?.reasoning_efforts).toEqual(["none", "low", "medium", "high"]); +}); + +test("filters to 100k+ context, function calling, image models, and known base models", () => { + const [gemini, oss, glm, tooSmall, noTools] = NeosantaraCatalogResponse.parse(catalogResponse).data; + + expect(meetsNeosantaraPublicFilter(gemini!)).toBe(true); + expect(meetsNeosantaraPublicFilter(oss!)).toBe(true); + expect(meetsNeosantaraPublicFilter(glm!)).toBe(true); + expect(meetsNeosantaraPublicFilter(tooSmall!)).toBe(false); // < 100k context + expect(meetsNeosantaraPublicFilter(noTools!)).toBe(false); // no function calling + + expect(shouldSyncNeosantaraModel(gemini!)).toBe(true); + expect(shouldSyncNeosantaraModel(tooSmall!)).toBe(false); + expect(shouldSyncNeosantaraModel(noTools!)).toBe(false); + + expect(resolveNeosantaraBaseModel("gemini-3.7-flash")).toBe("google/gemini-3.7-flash"); + expect( + resolveNeosantaraBaseModel({ id: "claude-4.5-sonnet", base_model: "anthropic/claude-sonnet-4-5" } as never), + ).toBe("anthropic/claude-sonnet-4-5"); + expect( + resolveNeosantaraBaseModel({ id: "grok-code-fast", base_model: "xai/grok-4.3" } as never), + ).toBe("xai/grok-4.3"); + expect( + resolveNeosantaraBaseModel({ id: "gemini-3.7-flash", base_model: "google/nonexistent-model" } as never), + ).toBe("google/gemini-3.7-flash"); + expect( + resolveNeosantaraBaseModel({ id: "unknown-id", base_model: "nonexistent/fake-model" } as never), + ).toBeUndefined(); + expect(resolveNeosantaraBaseModel("definitely-not-a-model")).toBeUndefined(); +}); + +test("builds override-only models with per-million USD cost and host reasoning controls", () => { + const [gemini] = NeosantaraCatalogResponse.parse(catalogResponse).data; + const built = buildNeosantaraModel(gemini!, undefined); + + expect(built).toMatchObject({ + base_model: "google/gemini-3.7-flash", + // The catalog's per-model reasoning_efforts are copied verbatim (host resolves them from the + // model's lab entry), intersected with the host enum. + reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high"] }], + interleaved: { field: "reasoning_content" }, + limit: { context: 1_000_000 }, + cost: { input: 1.5, output: 6, cache_read: 0.15, cache_write: 1.5 }, + }); + // internal_reasoning "0" is unknown, not a published zero. + expect((built as { cost?: { reasoning?: number } }).cost?.reasoning).toBeUndefined(); + // The gateway omits max_output; never author a model output limit from a runtime cap. + expect((built as { limit?: { output?: number } }).limit?.output).toBeUndefined(); +}); + +test("keeps a free model's zero token price instead of skipping it", () => { + const glm = NeosantaraCatalogResponse.parse(catalogResponse).data.find((m) => m.id === "glm-4.7-flash")!; + expect(shouldSyncNeosantaraModel(glm)).toBe(true); + + const built = buildNeosantaraModel(glm, undefined); + expect(built.cost).toMatchObject({ input: 0, output: 0 }); + // reasoning matches the base (factored out), but its effort controls are still authored. + expect(built.reasoning_options).toBeDefined(); +}); + +test("a non-reasoning model carries no reasoning controls, interleaved, or note", () => { + const oss = NeosantaraCatalogResponse.parse(catalogResponse).data.find((m) => m.id === "gpt-oss-20b")!; + const built = buildNeosantaraModel(oss, undefined); + + expect(built.reasoning).toBe(false); + expect(built.reasoning_options).toBeUndefined(); + expect(built.interleaved).toBeUndefined(); + + const translated = neosantara.translateModel(oss, { + existing: () => undefined, + authored: () => undefined, + }); + expect(translated?.header ?? "").not.toContain("reasoning_effort"); +}); + +test("copies the catalog's per-model reasoning surface verbatim (llmgateway convention)", () => { + const ctl = (efforts) => + neosantaraReasoningControls({ providers: [{ reasoning: true, reasoning_efforts: efforts }] } as never); + + // Exactly ["none"] -> on/off toggle. + expect(ctl(["none"])).toEqual([{ type: "toggle" }]); + // [] -> always-on (reasons, no caller control). + expect(ctl([])).toEqual([]); + // Graded levels are copied verbatim (the host resolved them from the model's lab entry), + // intersected with the host enum; out-of-enum values are dropped. + expect(ctl(["minimal", "low", "medium", "high"])).toEqual([ + { type: "effort", values: ["minimal", "low", "medium", "high"] }, + ]); + expect(ctl(["none", "high", "max"])).toEqual([{ type: "effort", values: ["none", "high", "max"] }]); + expect(ctl(["low", "high", "max", "bogus"])).toEqual([{ type: "effort", values: ["low", "high", "max"] }]); + + // Graded levels with "none" author only effort with "none" in values, without toggle. + expect( + neosantaraReasoningControls({ + providers: [{ reasoning: true, reasoning_efforts: ["low", "medium", "high", "xhigh", "max"] }], + } as never), + ).toEqual([ + { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, + ]); + + expect( + neosantaraReasoningControls({ + providers: [{ reasoning: true, reasoning_efforts: ["none", "low", "high", "max"] }], + } as never), + ).toEqual([ + { type: "effort", values: ["none", "low", "high", "max"] }, + ]); + + // Toggle is driven only by the catalog (has_toggle / ["none"]), not re-inferred from prior TOMLs. + expect( + neosantaraReasoningControls( + { + providers: [{ reasoning: true, reasoning_efforts: ["low", "high", "max"] }], + } as never, + { reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }] } as never, + ), + ).toEqual([{ type: "effort", values: ["low", "high", "max"] }]); +}); + +test("skips a reasoning model whose effort surface is unknown (missing != always-on)", () => { + const source = NeosantaraCatalogResponse.parse(catalogResponse).data[0]!; + // Reasoning model with a real surface syncs. + expect(shouldSyncNeosantaraModel(source)).toBe(true); + + // Same model but the catalog omits reasoning_efforts -> unknown -> skipped and reported, + // never stamped as always-on `[]`. + const unknown = { ...source, providers: [{ ...source.providers[0], reasoning_efforts: undefined }] }; + const parsed = NeosantaraCatalogResponse.parse({ data: [unknown] }).data[0]!; + expect(shouldSyncNeosantaraModel(parsed)).toBe(false); + expect(neosantara.sourceID(parsed)).toBe(parsed.id); + + // Explicit [] is different: it is an affirmative always-on set and still syncs. + const alwaysOn = { ...source, providers: [{ ...source.providers[0], reasoning_efforts: [] }] }; + const parsedAlwaysOn = NeosantaraCatalogResponse.parse({ data: [alwaysOn] }).data[0]!; + expect(shouldSyncNeosantaraModel(parsedAlwaysOn)).toBe(true); + expect(buildNeosantaraModel(parsedAlwaysOn, undefined).reasoning_options).toEqual([]); +}); + +test("toggle models carry a wire-comment header; effort models carry none", () => { + const models = NeosantaraCatalogResponse.parse(catalogResponse).data; + const ctx = { existing: () => undefined, authored: () => undefined }; + + // glm-4.7-flash advertises ["none"] -> toggle -> header names the wire field. + const toggle = neosantara.translateModel(models.find((m) => m.id === "glm-4.7-flash")!, ctx); + expect(toggle?.header).toContain("reasoning_effort"); + expect(toggle?.header).toContain("Toggle"); + expect(toggle?.header).toContain("https://docs.neosantara.xyz/en/capability/reasoning"); + + // gemini-3.7-flash advertises graded effort -> no toggle header. + const effort = neosantara.translateModel(models[0]!, ctx); + expect(effort?.header ?? "").not.toContain("Toggle"); + + // minimax-m2.7 documents always-on reasoning. + expect(neosantaraReasoningHeader([], "minimax-m2.7")).toContain("Always-on thinking"); + // kimi-k2-thinking documents dedicated thinking variant. + expect(neosantaraReasoningHeader([], "kimi-k2-thinking")).toContain("Dedicated thinking variant"); + // muse-glimmer-30b documents external baseline sources. + expect(neosantaraReasoningHeader([{ type: "effort", values: ["low"] as never }], "muse-glimmer-30b")).toContain( + "https://huggingface.co/meta-models/Muse-Glimmer-30B", + ); +}); + +test("syncs image-generation models on per-image pricing without token cost", () => { + const image = { + id: "gpt-image-2", + name: "gpt-image-2", + display_name: "Neosantara: Gpt Image 2", + created: 1_700_000_000, + description: "Image generation", + family: "openai", + architecture: { input_modalities: ["text"], output_modalities: ["image"] }, + context_length: 0, + pricing: {}, + supported_parameters: [], + structured_outputs: false, + providers: [{ providerId: "neosantara", vision: false, tools: false, reasoning: false }], + deprecated: false, + }; + const parsed = NeosantaraCatalogResponse.parse({ data: [image] }).data[0]!; + + expect(shouldSyncNeosantaraModel(parsed)).toBe(true); + const built = buildNeosantaraModel(parsed, undefined); + expect("base_model" in built && built.base_model).toBe("openai/gpt-image-2"); + expect(built.cost).toBeUndefined(); + expect(built.reasoning_options).toBeUndefined(); + // No limit.context = 0 authored for a context-less image model. + expect((built as { limit?: { context?: number } }).limit?.context).toBeUndefined(); +}); + +test("filters deprecated models and reports unmapped ids as skipped", () => { + const deprecated = { ...catalogResponse.data[0]!, deprecated: true }; + expect(shouldSyncNeosantaraModel(NeosantaraCatalogResponse.parse({ data: [deprecated] }).data[0]!)).toBe(false); + + const unmapped = { ...catalogResponse.data[0]!, id: "not-in-the-canonical-tree" }; + const parsed = NeosantaraCatalogResponse.parse({ data: [unmapped] }).data[0]!; + expect(shouldSyncNeosantaraModel(parsed)).toBe(false); + expect(neosantara.sourceID(parsed)).toBe("not-in-the-canonical-tree"); + + const notice = neosantara.skippedNotice(["not-in-the-canonical-tree"]); + expect(notice.join("\n")).toContain("not-in-the-canonical-tree"); + expect(neosantara.skippedNotice([])).toEqual([]); +}); + +test("an empty catalog is fatal rather than wiping every model", () => { + expect(() => neosantara.parseModels({ data: [] })).toThrow(); +}); + +test("deploymentModalities strips non-text inputs when vision is false and preserves otherwise", () => { + const multimodal = { + architecture: { + input_modalities: ["text", "image", "video", "pdf"], + output_modalities: ["text"], + }, + } as never; + + expect(deploymentModalities(multimodal, false)).toEqual({ + input: ["text"], + output: ["text"], + }); + + expect(deploymentModalities(multimodal, true)).toEqual({ + input: ["text", "image", "video", "pdf"], + output: ["text"], + }); + + expect(deploymentModalities(multimodal, undefined)).toEqual({ + input: ["text", "image", "video", "pdf"], + output: ["text"], + }); +}); + +test("enforces text-only input and attachment=false when vision is false on multimodal base model", () => { + const model = { + id: "kimi-k3", + context_length: 1_048_576, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.000003000000", completion: "0.000015000000" }, + providers: [{ providerId: "neosantara", vision: false, tools: true, reasoning: false }], + } as never; + + const built = buildNeosantaraModel(model, undefined); + // moonshotai/kimi-k3 base model has attachment=true and input=["text", "image", "video"]. + // Because vision is false on this host, attachment must be false AND input must be overridden to ["text"]. + expect(built).toMatchObject({ + base_model: "moonshotai/kimi-k3", + attachment: false, + modalities: { input: ["text"] }, + }); + // tool_call is true in both host and base model; factored out + expect("tool_call" in built).toBe(false); +}); + +test("emits narrower modalities when host deployment does not support pdf/video", () => { + const model = { + id: "claude-4.5-opus", + base_model: "anthropic/claude-opus-4-5", + context_length: 200_000, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + pricing: { prompt: "0.000005000000", completion: "0.000025000000" }, + providers: [{ providerId: "neosantara", vision: true, tools: true, reasoning: false }], + } as never; + + const built = buildNeosantaraModel(model, undefined); + // anthropic/claude-opus-4-5 base model has attachment=true and input=["text", "image", "pdf"]. + // Because vision is true, attachment is true matching base and factored out. + // Because host deployment only serves text+image (not pdf), narrower input modalities are authored. + expect("attachment" in built).toBe(false); + expect(built.modalities).toEqual({ input: ["text", "image"] }); +}); + +test("omits modalities when host input modalities match base model", () => { + const model = { + id: "claude-4.5-opus", + base_model: "anthropic/claude-opus-4-5", + context_length: 200_000, + architecture: { input_modalities: ["text", "image", "pdf"], output_modalities: ["text"] }, + pricing: { prompt: "0.000005000000", completion: "0.000025000000" }, + providers: [{ providerId: "neosantara", vision: true, tools: true, reasoning: false }], + } as never; + + const built = buildNeosantaraModel(model, undefined); + expect("attachment" in built).toBe(false); + expect("modalities" in built).toBe(false); +}); + diff --git a/providers/neosantara/logo.svg b/providers/neosantara/logo.svg new file mode 100644 index 00000000000..b03dbb9aac1 --- /dev/null +++ b/providers/neosantara/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/providers/neosantara/models/claude-4.5-opus.toml b/providers/neosantara/models/claude-4.5-opus.toml new file mode 100644 index 00000000000..a4d4256c940 --- /dev/null +++ b/providers/neosantara/models/claude-4.5-opus.toml @@ -0,0 +1,17 @@ +base_model = "anthropic/claude-opus-4-5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high"] + +[cost] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-4.5-sonnet.toml b/providers/neosantara/models/claude-4.5-sonnet.toml new file mode 100644 index 00000000000..ff2be1b7756 --- /dev/null +++ b/providers/neosantara/models/claude-4.5-sonnet.toml @@ -0,0 +1,18 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "anthropic/claude-sonnet-4-5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 3 +output = 15 +cache_read = 0.3 +cache_write = 3.75 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-fable-5.toml b/providers/neosantara/models/claude-fable-5.toml new file mode 100644 index 00000000000..975c0ff8372 --- /dev/null +++ b/providers/neosantara/models/claude-fable-5.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-fable-5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 10 +output = 50 +cache_read = 1 +cache_write = 12.5 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-opus-4-6.toml b/providers/neosantara/models/claude-opus-4-6.toml new file mode 100644 index 00000000000..855d25016e7 --- /dev/null +++ b/providers/neosantara/models/claude-opus-4-6.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-opus-4-6" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "max"] + +[cost] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-opus-4-7.toml b/providers/neosantara/models/claude-opus-4-7.toml new file mode 100644 index 00000000000..edf73248854 --- /dev/null +++ b/providers/neosantara/models/claude-opus-4-7.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-opus-4-7" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-opus-4-8.toml b/providers/neosantara/models/claude-opus-4-8.toml new file mode 100644 index 00000000000..752a1fee6bb --- /dev/null +++ b/providers/neosantara/models/claude-opus-4-8.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-opus-4-8" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-opus-5.toml b/providers/neosantara/models/claude-opus-5.toml new file mode 100644 index 00000000000..f12a8d29ae3 --- /dev/null +++ b/providers/neosantara/models/claude-opus-5.toml @@ -0,0 +1,15 @@ +base_model = "anthropic/claude-opus-5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 5 +output = 25 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-sonnet-4-6.toml b/providers/neosantara/models/claude-sonnet-4-6.toml new file mode 100644 index 00000000000..1744aacaf58 --- /dev/null +++ b/providers/neosantara/models/claude-sonnet-4-6.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-sonnet-4-6" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "max"] + +[cost] +input = 3 +output = 15 +cache_read = 0.3 +cache_write = 3.75 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/claude-sonnet-5.toml b/providers/neosantara/models/claude-sonnet-5.toml new file mode 100644 index 00000000000..b3e3e1cc876 --- /dev/null +++ b/providers/neosantara/models/claude-sonnet-5.toml @@ -0,0 +1,20 @@ +base_model = "anthropic/claude-sonnet-5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh", "max"] + +[cost] +input = 3 +output = 15 +cache_read = 0.3 +cache_write = 3.75 + +[limit] +context = 200_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/deepseek-v4-flash-0731.toml b/providers/neosantara/models/deepseek-v4-flash-0731.toml new file mode 100644 index 00000000000..41f59d2d4a0 --- /dev/null +++ b/providers/neosantara/models/deepseek-v4-flash-0731.toml @@ -0,0 +1,13 @@ +base_model = "deepseek/deepseek-v4-flash-0731" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "high", "max"] + +[cost] +input = 0.14 +output = 0.28 +cache_read = 0.03 diff --git a/providers/neosantara/models/deepseek-v4-flash.toml b/providers/neosantara/models/deepseek-v4-flash.toml new file mode 100644 index 00000000000..52a52f369f2 --- /dev/null +++ b/providers/neosantara/models/deepseek-v4-flash.toml @@ -0,0 +1,14 @@ +base_model = "deepseek/deepseek-v4-flash-0731" +name = "DeepSeek V4 Flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "high", "max"] + +[cost] +input = 0.14 +output = 0.28 +cache_read = 0.0028 diff --git a/providers/neosantara/models/deepseek-v4-pro-0813.toml b/providers/neosantara/models/deepseek-v4-pro-0813.toml new file mode 100644 index 00000000000..e88728c0359 --- /dev/null +++ b/providers/neosantara/models/deepseek-v4-pro-0813.toml @@ -0,0 +1,13 @@ +base_model = "deepseek/deepseek-v4-pro-0813" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "high", "max"] + +[cost] +input = 1.32 +output = 3.96 +cache_read = 0.13 diff --git a/providers/neosantara/models/deepseek-v4-pro.toml b/providers/neosantara/models/deepseek-v4-pro.toml new file mode 100644 index 00000000000..9ed5e2a9018 --- /dev/null +++ b/providers/neosantara/models/deepseek-v4-pro.toml @@ -0,0 +1,14 @@ +base_model = "deepseek/deepseek-v4-pro-0813" +name = "DeepSeek V4 Pro" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "high", "max"] + +[cost] +input = 2.4 +output = 4.8 +cache_read = 0.024 diff --git a/providers/neosantara/models/devstral-2.toml b/providers/neosantara/models/devstral-2.toml new file mode 100644 index 00000000000..19e31206c56 --- /dev/null +++ b/providers/neosantara/models/devstral-2.toml @@ -0,0 +1,8 @@ +base_model = "mistral/devstral-2512" + +[cost] +input = 0.4 +output = 2 + +[limit] +context = 256_000 diff --git a/providers/neosantara/models/gemini-3.1-flash-lite.toml b/providers/neosantara/models/gemini-3.1-flash-lite.toml new file mode 100644 index 00000000000..0e87adb20c3 --- /dev/null +++ b/providers/neosantara/models/gemini-3.1-flash-lite.toml @@ -0,0 +1,19 @@ +base_model = "google/gemini-3.1-flash-lite" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[cost] +input = 0.25 +output = 1.5 +cache_read = 0.025 + +[limit] +context = 1_000_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gemini-3.5-flash.toml b/providers/neosantara/models/gemini-3.5-flash.toml new file mode 100644 index 00000000000..4b6ba44c0a3 --- /dev/null +++ b/providers/neosantara/models/gemini-3.5-flash.toml @@ -0,0 +1,20 @@ +base_model = "google/gemini-3.5-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[cost] +input = 1.5 +output = 9 +cache_read = 0.15 +cache_write = 1.5 + +[limit] +context = 1_000_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gemini-3.6-flash.toml b/providers/neosantara/models/gemini-3.6-flash.toml new file mode 100644 index 00000000000..4065d09c643 --- /dev/null +++ b/providers/neosantara/models/gemini-3.6-flash.toml @@ -0,0 +1,20 @@ +base_model = "google/gemini-3.6-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[cost] +input = 0.75 +output = 3.75 +cache_read = 0.075 +cache_write = 0.75 + +[limit] +context = 1_000_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gemini-3.7-flash.toml b/providers/neosantara/models/gemini-3.7-flash.toml new file mode 100644 index 00000000000..46aa16386a5 --- /dev/null +++ b/providers/neosantara/models/gemini-3.7-flash.toml @@ -0,0 +1,20 @@ +base_model = "google/gemini-3.7-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high"] + +[cost] +input = 0.75 +output = 3.75 +cache_read = 0.075 +cache_write = 0.75 + +[limit] +context = 1_000_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/glm-4.5-flash.toml b/providers/neosantara/models/glm-4.5-flash.toml new file mode 100644 index 00000000000..d2f6da84cc5 --- /dev/null +++ b/providers/neosantara/models/glm-4.5-flash.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "zhipuai/glm-4.5-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.015625 +output = 0.046875 + +[limit] +context = 128_000 diff --git a/providers/neosantara/models/glm-4.6v-flash.toml b/providers/neosantara/models/glm-4.6v-flash.toml new file mode 100644 index 00000000000..45ff43eae5b --- /dev/null +++ b/providers/neosantara/models/glm-4.6v-flash.toml @@ -0,0 +1,13 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "zhipuai/glm-4.6v-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.015625 +output = 0.046875 diff --git a/providers/neosantara/models/glm-4.7-flash.toml b/providers/neosantara/models/glm-4.7-flash.toml new file mode 100644 index 00000000000..dc643bcc6f6 --- /dev/null +++ b/providers/neosantara/models/glm-4.7-flash.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "zhipuai/glm-4.7-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0 +output = 0 + +[limit] +context = 1_000_000 diff --git a/providers/neosantara/models/glm-4.7.toml b/providers/neosantara/models/glm-4.7.toml new file mode 100644 index 00000000000..716b59f69a2 --- /dev/null +++ b/providers/neosantara/models/glm-4.7.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "zhipuai/glm-4.7" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.625 +output = 1.875 + +[limit] +context = 200_000 diff --git a/providers/neosantara/models/glm-5.3-flash.toml b/providers/neosantara/models/glm-5.3-flash.toml new file mode 100644 index 00000000000..a09d695afea --- /dev/null +++ b/providers/neosantara/models/glm-5.3-flash.toml @@ -0,0 +1,16 @@ +base_model = "zhipuai/glm-5.3-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + +[cost] +input = 0.15 +output = 0.5 +cache_read = 0.03 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gpt-5-nano.toml b/providers/neosantara/models/gpt-5-nano.toml new file mode 100644 index 00000000000..1d8b65e0bc4 --- /dev/null +++ b/providers/neosantara/models/gpt-5-nano.toml @@ -0,0 +1,16 @@ +base_model = "openai/gpt-5-nano" +base_model_omit = ["limit.input"] + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high"] + +[cost] +input = 0.050625 +output = 0.405313 + +[limit] +context = 128_000 diff --git a/providers/neosantara/models/gpt-5.4-mini.toml b/providers/neosantara/models/gpt-5.4-mini.toml new file mode 100644 index 00000000000..43dfdad1d82 --- /dev/null +++ b/providers/neosantara/models/gpt-5.4-mini.toml @@ -0,0 +1,18 @@ +base_model = "openai/gpt-5.4-mini" +base_model_omit = ["limit.input"] + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 0.75 +output = 4.5 +cache_read = 0.075 +cache_write = 0.75 + +[limit] +context = 128_000 diff --git a/providers/neosantara/models/gpt-5.4-nano.toml b/providers/neosantara/models/gpt-5.4-nano.toml new file mode 100644 index 00000000000..0c7f54843de --- /dev/null +++ b/providers/neosantara/models/gpt-5.4-nano.toml @@ -0,0 +1,12 @@ +base_model = "openai/gpt-5.4-nano" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 0.2025 +output = 1.265625 diff --git a/providers/neosantara/models/gpt-5.4.toml b/providers/neosantara/models/gpt-5.4.toml new file mode 100644 index 00000000000..148f0ef35b4 --- /dev/null +++ b/providers/neosantara/models/gpt-5.4.toml @@ -0,0 +1,20 @@ +base_model = "openai/gpt-5.4" +base_model_omit = ["limit.input"] + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 2.5 +output = 15 +cache_read = 0.25 + +[limit] +context = 270_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gpt-5.5.toml b/providers/neosantara/models/gpt-5.5.toml new file mode 100644 index 00000000000..8c2ec38ca96 --- /dev/null +++ b/providers/neosantara/models/gpt-5.5.toml @@ -0,0 +1,20 @@ +base_model = "openai/gpt-5.5" +base_model_omit = ["limit.input"] + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 5 +output = 30 +cache_read = 0.5 + +[limit] +context = 270_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gpt-5.6-luna.toml b/providers/neosantara/models/gpt-5.6-luna.toml new file mode 100644 index 00000000000..a7094fb9a76 --- /dev/null +++ b/providers/neosantara/models/gpt-5.6-luna.toml @@ -0,0 +1,20 @@ +base_model = "openai/gpt-5.6-luna" +base_model_omit = ["limit.input"] +attachment = false + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh", "max"] + +[cost] +input = 0.2 +output = 1.2 + +[limit] +context = 272_000 + +[modalities] +input = ["text"] diff --git a/providers/neosantara/models/gpt-5.6-sol.toml b/providers/neosantara/models/gpt-5.6-sol.toml new file mode 100644 index 00000000000..d7235e662e9 --- /dev/null +++ b/providers/neosantara/models/gpt-5.6-sol.toml @@ -0,0 +1,20 @@ +base_model = "openai/gpt-5.6-sol" +base_model_omit = ["limit.input"] +attachment = false + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh", "max"] + +[cost] +input = 5 +output = 30 + +[limit] +context = 272_000 + +[modalities] +input = ["text"] diff --git a/providers/neosantara/models/gpt-5.6-terra.toml b/providers/neosantara/models/gpt-5.6-terra.toml new file mode 100644 index 00000000000..84820250e5c --- /dev/null +++ b/providers/neosantara/models/gpt-5.6-terra.toml @@ -0,0 +1,19 @@ +base_model = "openai/gpt-5.6-terra" +base_model_omit = ["limit.input"] + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh", "max"] + +[cost] +input = 2 +output = 12 + +[limit] +context = 272_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/gpt-image-2.toml b/providers/neosantara/models/gpt-image-2.toml new file mode 100644 index 00000000000..1ff78c6e696 --- /dev/null +++ b/providers/neosantara/models/gpt-image-2.toml @@ -0,0 +1 @@ +base_model = "openai/gpt-image-2" diff --git a/providers/neosantara/models/gpt-oss-120b.toml b/providers/neosantara/models/gpt-oss-120b.toml new file mode 100644 index 00000000000..b1cf0808e38 --- /dev/null +++ b/providers/neosantara/models/gpt-oss-120b.toml @@ -0,0 +1,13 @@ +base_model = "openai/gpt-oss-120b" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high"] + +[cost] +input = 0.15 +output = 0.6 +cache_read = 0.075 diff --git a/providers/neosantara/models/gpt-oss-20b.toml b/providers/neosantara/models/gpt-oss-20b.toml new file mode 100644 index 00000000000..ffa8cf409b0 --- /dev/null +++ b/providers/neosantara/models/gpt-oss-20b.toml @@ -0,0 +1,12 @@ +base_model = "openai/gpt-oss-20b" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high"] + +[cost] +input = 0.025 +output = 0.1 diff --git a/providers/neosantara/models/kimi-k2-thinking.toml b/providers/neosantara/models/kimi-k2-thinking.toml new file mode 100644 index 00000000000..2fbdb7e22cc --- /dev/null +++ b/providers/neosantara/models/kimi-k2-thinking.toml @@ -0,0 +1,14 @@ +# Dedicated thinking variant: always-on reasoning with no caller control. +# Matches lab providers/moonshotai/models/kimi-k2-thinking.toml and peers OpenRouter/Vercel. +base_model = "moonshotai/kimi-k2-thinking" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.6 +output = 2.5 + +[limit] +context = 256_000 diff --git a/providers/neosantara/models/kimi-k2.5.toml b/providers/neosantara/models/kimi-k2.5.toml new file mode 100644 index 00000000000..d451cf021ce --- /dev/null +++ b/providers/neosantara/models/kimi-k2.5.toml @@ -0,0 +1,19 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "moonshotai/kimi-k2.5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.6 +output = 3 + +[limit] +context = 256_000 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/kimi-k2.6.toml b/providers/neosantara/models/kimi-k2.6.toml new file mode 100644 index 00000000000..f0758670743 --- /dev/null +++ b/providers/neosantara/models/kimi-k2.6.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "moonshotai/kimi-k2.6" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.285 +output = 1.2 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/kimi-k3.toml b/providers/neosantara/models/kimi-k3.toml new file mode 100644 index 00000000000..d6c9b2067ad --- /dev/null +++ b/providers/neosantara/models/kimi-k3.toml @@ -0,0 +1,16 @@ +base_model = "moonshotai/kimi-k3" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "high", "max"] + +[cost] +input = 3 +output = 15 +cache_read = 0.3 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/laguna-s-2.1.toml b/providers/neosantara/models/laguna-s-2.1.toml new file mode 100644 index 00000000000..3d9fab5f556 --- /dev/null +++ b/providers/neosantara/models/laguna-s-2.1.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "poolside/laguna-s-2.1" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0 +output = 0 + +[limit] +context = 262_144 diff --git a/providers/neosantara/models/laguna-xs-2.1.toml b/providers/neosantara/models/laguna-xs-2.1.toml new file mode 100644 index 00000000000..62eb6836203 --- /dev/null +++ b/providers/neosantara/models/laguna-xs-2.1.toml @@ -0,0 +1,13 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "poolside/laguna-xs-2.1" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0 +output = 0 diff --git a/providers/neosantara/models/ling-3.0-flash-fin.toml b/providers/neosantara/models/ling-3.0-flash-fin.toml new file mode 100644 index 00000000000..756cf791b35 --- /dev/null +++ b/providers/neosantara/models/ling-3.0-flash-fin.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "inclusionai/ling-3.0-flash-fin" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0 +output = 0 + +[limit] +context = 256_000 diff --git a/providers/neosantara/models/llama-3.3-nemotron-super-49b-v1.5.toml b/providers/neosantara/models/llama-3.3-nemotron-super-49b-v1.5.toml new file mode 100644 index 00000000000..55db32f9e29 --- /dev/null +++ b/providers/neosantara/models/llama-3.3-nemotron-super-49b-v1.5.toml @@ -0,0 +1,16 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "nvidia/llama-3.3-nemotron-super-49b-v1.5" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.285 +output = 1.999437 + +[limit] +context = 132_000 diff --git a/providers/neosantara/models/minimax-m2.7.toml b/providers/neosantara/models/minimax-m2.7.toml new file mode 100644 index 00000000000..c92cde098a0 --- /dev/null +++ b/providers/neosantara/models/minimax-m2.7.toml @@ -0,0 +1,11 @@ +# Always-on thinking: upstream Dahl forwards no reasoning_effort parameter. +# Matches lab providers/minimax/models/MiniMax-M2.7.toml and peers OpenRouter/FastRouter/Cortecs. +base_model = "minimax/MiniMax-M2.7" +reasoning_options = [] + +[interleaved] +field = "reasoning_content" + +[cost] +input = 0.09 +output = 0.36 diff --git a/providers/neosantara/models/minimax-m3.toml b/providers/neosantara/models/minimax-m3.toml new file mode 100644 index 00000000000..e82398345b9 --- /dev/null +++ b/providers/neosantara/models/minimax-m3.toml @@ -0,0 +1,20 @@ +# Toggle: reasoning_effort = "none" turns thinking off; any other accepted +# value turns it on. https://docs.neosantara.xyz/en/capability/reasoning +base_model = "minimax/MiniMax-M3" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "toggle" + +[cost] +input = 0.3 +output = 1.2 +cache_read = 0.06 + +[limit] +context = 524_288 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/mistral-large-latest.toml b/providers/neosantara/models/mistral-large-latest.toml new file mode 100644 index 00000000000..dc4f069d5f2 --- /dev/null +++ b/providers/neosantara/models/mistral-large-latest.toml @@ -0,0 +1,12 @@ +base_model = "mistral/mistral-large-latest" +attachment = false + +[cost] +input = 0.5 +output = 1.5 + +[limit] +context = 128_000 + +[modalities] +input = ["text"] diff --git a/providers/neosantara/models/mistral-small-latest.toml b/providers/neosantara/models/mistral-small-latest.toml new file mode 100644 index 00000000000..c2f93013a33 --- /dev/null +++ b/providers/neosantara/models/mistral-small-latest.toml @@ -0,0 +1,13 @@ +base_model = "mistral/mistral-small-latest" +attachment = false +reasoning = false + +[cost] +input = 0.15 +output = 0.6 + +[limit] +context = 128_000 + +[modalities] +input = ["text"] diff --git a/providers/neosantara/models/muse-glimmer-30b.toml b/providers/neosantara/models/muse-glimmer-30b.toml new file mode 100644 index 00000000000..63fdbc24b80 --- /dev/null +++ b/providers/neosantara/models/muse-glimmer-30b.toml @@ -0,0 +1,17 @@ +# Sources: +# https://huggingface.co/meta-models/Muse-Glimmer-30B +# Effort: reasoning_effort = low|medium|high|xhigh +# Matches peers OpenRouter and Vercel AI Gateway. +base_model = "meta/muse-glimmer-30b" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh"] + +[cost] +input = 0.35 +output = 1.5 +cache_read = 0.04 diff --git a/providers/neosantara/models/muse-spark-1.1.toml b/providers/neosantara/models/muse-spark-1.1.toml new file mode 100644 index 00000000000..a62e2e536c1 --- /dev/null +++ b/providers/neosantara/models/muse-spark-1.1.toml @@ -0,0 +1,16 @@ +base_model = "meta/muse-spark-1.1" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["minimal", "low", "medium", "high", "xhigh"] + +[cost] +input = 1.25 +output = 4.25 +cache_read = 0.15 + +[modalities] +input = ["text", "image"] diff --git a/providers/neosantara/models/step-3.5-flash.toml b/providers/neosantara/models/step-3.5-flash.toml new file mode 100644 index 00000000000..b092cac10ae --- /dev/null +++ b/providers/neosantara/models/step-3.5-flash.toml @@ -0,0 +1,12 @@ +base_model = "stepfun/step-3.5-flash" + +[interleaved] +field = "reasoning_content" + +[[reasoning_options]] +type = "effort" +values = ["low", "high"] + +[cost] +input = 0.1875 +output = 0.5625 diff --git a/providers/neosantara/provider.toml b/providers/neosantara/provider.toml new file mode 100644 index 00000000000..bcd8aa05c31 --- /dev/null +++ b/providers/neosantara/provider.toml @@ -0,0 +1,27 @@ +# Source (accessed 2026-09-03): +# - https://docs.neosantara.xyz +# - https://api.neosantara.xyz/v1/catalog (models.dev / LLM Gateway shape, public, no key) +# +# The sync consumes the single /v1/catalog endpoint. The gateway does the host-specific work +# upstream (USD-per-token pricing incl. IDR conversion and any discount, capability flags, and +# the reasoning surface), so the sync is a thin, override-only translation. +# +# Reasoning: the catalog reports each model's real per-model effort surface in providers[]. +# reasoning_efforts, mapped to reasoning_options as: +# [] -> [] (always-on: reasons, no caller control) +# ["none"] -> toggle (reasoning_effort = "none" is off; carries a leading wire comment) +# graded set -> effort, using the model's real levels (lab + same-surface peers), never a +# full enum. Host ceiling is none|minimal|low|medium|high|xhigh|max. +# When off is expressed via reasoning_effort = "none" alongside graded levels, AGENTS.md strictly +# requires authoring only `effort` with `none` in values and NO toggle. Only binary on/off models +# (no graded levels, only ["none"]) author toggle alone with a wire comment. Dedicated headers +# document always-on relays (minimax-m2.7, kimi-k2-thinking) and external citations. +# +# Streaming: Neosantara normalizes reasoning output across all upstream providers into the +# OpenAI-compatible delta.reasoning_content field (see https://docs.neosantara.xyz/en/capability/reasoning). +# All reasoning models therefore advertise interleaved.field = "reasoning_content". +name = "Neosantara" +env = ["NEOSANTARA_API_KEY"] +npm = "@ai-sdk/openai-compatible" +api = "https://api.neosantara.xyz/v1" +doc = "https://docs.neosantara.xyz"