Add Novita provider

This commit is contained in:
freecodewu
2026-07-09 19:46:11 +08:00
parent 29deeef876
commit a45ecd5593
6 changed files with 3086 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
import { createApiKeyLogin } from "./api-key-login";
import type { ProviderDefinition } from "./types";
export const loginNovita = createApiKeyLogin({
providerLabel: "Novita",
authUrl: "https://novita.ai/settings/key-management",
instructions: "Create or copy your API key from the Novita dashboard",
promptMessage: "Paste your Novita API key",
placeholder: "sk_...",
validation: {
kind: "models-endpoint",
provider: "novita",
modelsUrl: "https://api.novita.ai/openai/v1/models",
},
});
export const novitaProvider = {
id: "novita",
name: "Novita",
login: (cb: Parameters<typeof loginNovita>[0]) => loginNovita(cb),
} as const satisfies ProviderDefinition;
+2
View File
@@ -34,6 +34,7 @@ import { minimaxCodeCnProvider } from "./minimax-code-cn";
import { mistralProvider } from "./mistral";
import { moonshotProvider } from "./moonshot";
import { nanogptProvider } from "./nanogpt";
import { novitaProvider } from "./novita";
import { nvidiaProvider } from "./nvidia";
import { ollamaProvider } from "./ollama";
import { ollamaCloudProvider } from "./ollama-cloud";
@@ -110,6 +111,7 @@ const ALL = [
fireworksProvider,
togetherProvider,
nvidiaProvider,
novitaProvider,
huggingfaceProvider,
perplexityProvider,
qianfanProvider,
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ import {
mistralModelManagerOptions,
moonshotModelManagerOptions,
nanoGptModelManagerOptions,
novitaModelManagerOptions,
nvidiaModelManagerOptions,
ollamaModelManagerOptions,
openaiModelManagerOptions,
@@ -271,6 +272,13 @@ export const CATALOG_PROVIDERS = [
createModelManagerOptions: (config: ModelManagerConfig) => nvidiaModelManagerOptions(config),
catalogDiscovery: { label: "NVIDIA" },
},
{
id: "novita",
defaultModel: "moonshotai/kimi-k2.7-code",
envVars: ["NOVITA_API_KEY"],
createModelManagerOptions: (config: ModelManagerConfig) => novitaModelManagerOptions(config),
catalogDiscovery: { label: "Novita", allowUnauthenticated: true },
},
{
id: "ollama",
defaultModel: "gpt-oss:20b",
@@ -971,6 +971,100 @@ export function nvidiaModelManagerOptions(
return createSimpleOpenAICompletionsOptions("nvidia", "https://integrate.api.nvidia.com/v1", config);
}
// ---------------------------------------------------------------------------
// 5.5 Novita
// ---------------------------------------------------------------------------
export interface NovitaModelManagerConfig {
apiKey?: string;
baseUrl?: string;
fetch?: FetchImpl;
}
function novitaArrayIncludes(value: unknown, expected: string): boolean {
return Array.isArray(value) && value.some(item => item === expected);
}
function isPublicNovitaModelId(id: string): boolean {
return !id.toLowerCase().startsWith("ai_infer_test");
}
function toNovitaCostPerMillion(value: unknown): number {
return toPositiveNumber(value, 0) / 1000;
}
function getNovitaCacheReadPricePerMillion(entry: OpenAICompatibleModelRecord): number {
const pricing = entry.pricing;
if (!isRecord(pricing)) {
return 0;
}
const cacheRead = pricing.input_cache_read;
if (!isRecord(cacheRead)) {
return 0;
}
return toNovitaCostPerMillion(cacheRead.price_per_m);
}
function mapNovitaModel(
entry: OpenAICompatibleModelRecord,
defaults: ModelSpec<"openai-completions">,
reference: ModelSpec<"openai-completions"> | undefined,
): ModelSpec<"openai-completions"> {
const model = mapWithBundledReference(
{
...entry,
name: entry.display_name ?? entry.title ?? entry.name,
},
defaults,
reference,
);
return {
...model,
reasoning: novitaArrayIncludes(entry.features, "reasoning"),
supportsTools: novitaArrayIncludes(entry.features, "function-calling"),
input: toInputCapabilities(entry.input_modalities),
cost: {
input: toNovitaCostPerMillion(entry.input_token_price_per_m),
output: toNovitaCostPerMillion(entry.output_token_price_per_m),
cacheRead: getNovitaCacheReadPricePerMillion(entry),
cacheWrite: 0,
},
contextWindow: toPositiveNumber(entry.context_size, model.contextWindow),
maxTokens: toPositiveNumber(entry.max_output_tokens, model.maxTokens),
};
}
export function novitaModelManagerOptions(
config?: NovitaModelManagerConfig,
): ModelManagerOptions<"openai-completions"> {
const apiKey = config?.apiKey;
const baseUrl = config?.baseUrl ?? "https://api.novita.ai/openai/v1";
const references = createBundledReferenceMap<"openai-completions">(
"novita" as Parameters<typeof getBundledModels>[0],
);
return {
providerId: "novita",
fetchDynamicModels: async () =>
fetchOpenAICompatibleModels({
api: "openai-completions",
provider: "novita",
baseUrl,
apiKey,
mapModel: (entry, defaults) => mapNovitaModel(entry, defaults, references.get(defaults.id)),
filterModel: (entry, model) => {
const active = typeof entry.status !== "number" || entry.status === 1;
return (
active &&
isPublicNovitaModelId(model.id) &&
novitaArrayIncludes(entry.endpoints, "chat/completions") &&
model.maxTokens !== 0
);
},
fetch: config?.fetch,
}),
};
}
// ---------------------------------------------------------------------------
// 6. xAI
// ---------------------------------------------------------------------------
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth";
import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream";
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors";
import { novitaModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
describe("Novita built-in provider", () => {
test("registers catalog descriptor with NOVITA_API_KEY env discovery", () => {
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "novita");
expect(descriptor).toBeDefined();
expect(descriptor?.defaultModel).toBe("moonshotai/kimi-k2.7-code");
expect(descriptor?.catalogDiscovery?.envVars).toContain("NOVITA_API_KEY");
expect(descriptor?.catalogDiscovery?.allowUnauthenticated).toBe(true);
expect(DEFAULT_MODEL_PER_PROVIDER.novita).toBe("moonshotai/kimi-k2.7-code");
});
test("registers Novita as an API-key login provider", () => {
const provider = getOAuthProviders().find(item => item.id === "novita");
expect(provider?.name).toBe("Novita");
expect(provider?.available).toBe(true);
});
test("resolves NOVITA_API_KEY via env", () => {
const previous = Bun.env.NOVITA_API_KEY;
Bun.env.NOVITA_API_KEY = "novita-test-key";
try {
expect(getEnvApiKey("novita")).toBe("novita-test-key");
} finally {
if (previous === undefined) {
delete Bun.env.NOVITA_API_KEY;
} else {
Bun.env.NOVITA_API_KEY = previous;
}
}
});
test("maps Novita model catalog metadata from the public OpenAI-compatible endpoint", async () => {
const requests: string[] = [];
const fetchMock = async (input: string | URL | Request): Promise<Response> => {
requests.push(input.toString());
return Response.json({
data: [
{
id: "moonshotai/kimi-k2.7-code",
display_name: "Kimi K2.7 Code",
status: 1,
context_size: 262144,
max_output_tokens: 131072,
input_token_price_per_m: 9500,
output_token_price_per_m: 40000,
pricing: {
input_cache_read: {
price_per_m: 1900,
},
},
features: ["serverless", "function-calling", "structured-outputs", "reasoning"],
endpoints: ["chat/completions", "anthropic"],
input_modalities: ["text", "image", "video"],
},
{
id: "qwen/qwen3-8b-fp8",
status: 4,
context_size: 128000,
max_output_tokens: 20000,
endpoints: ["chat/completions"],
input_modalities: ["text"],
},
{
id: "ai_infer_test_1",
status: 1,
context_size: 200000,
max_output_tokens: 200000,
features: ["function-calling"],
endpoints: ["chat/completions"],
input_modalities: ["text"],
},
],
});
};
const options = novitaModelManagerOptions({ fetch: fetchMock });
const models = await options.fetchDynamicModels?.();
const model = models?.find(item => item.id === "moonshotai/kimi-k2.7-code");
expect(requests).toEqual(["https://api.novita.ai/openai/v1/models"]);
expect(models?.map(item => item.id)).toEqual(["moonshotai/kimi-k2.7-code"]);
expect(model?.provider).toBe("novita");
expect(model?.baseUrl).toBe("https://api.novita.ai/openai/v1");
expect(model?.name).toBe("Kimi K2.7 Code");
expect(model?.reasoning).toBe(true);
expect(model?.supportsTools).toBe(true);
expect(model?.input).toEqual(["text", "image"]);
expect(model?.cost).toEqual({ input: 9.5, output: 40, cacheRead: 1.9, cacheWrite: 0 });
expect(model?.contextWindow).toBe(262144);
expect(model?.maxTokens).toBe(131072);
});
});