From 804957261f93225bb909b0f3723a22204ab170cc Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Wed, 25 Mar 2026 12:05:43 +0800 Subject: [PATCH 1/2] feat: add Novita AI as LLM provider Add Novita AI (https://novita.ai) as a new LLM provider option. Novita offers OpenAI-compatible API endpoints with competitive pricing. --- server/_shared/llm-health.ts | 3 + server/_shared/llm.ts | 17 +++++- src-tauri/sidecar/local-api-server.mjs | 8 ++- tests/shared-llm.test.mts | 84 ++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/server/_shared/llm-health.ts b/server/_shared/llm-health.ts index eb6a21f941..dafda1319b 100644 --- a/server/_shared/llm-health.ts +++ b/server/_shared/llm-health.ts @@ -102,6 +102,9 @@ export function warmHealthCache(): void { if (typeof process !== 'undefined' && process.env?.OPENROUTER_API_KEY) { providerUrls.push('https://openrouter.ai/api/v1/chat/completions'); } + if (typeof process !== 'undefined' && process.env?.NOVITA_API_KEY) { + providerUrls.push('https://api.novita.ai/openai/v1/chat/completions'); + } for (const url of providerUrls) { void isProviderAvailable(url); diff --git a/server/_shared/llm.ts b/server/_shared/llm.ts index 5fdcd3e4ae..84a4de11ac 100644 --- a/server/_shared/llm.ts +++ b/server/_shared/llm.ts @@ -9,7 +9,7 @@ export interface ProviderCredentials { extraBody?: Record; } -export type LlmProviderName = 'ollama' | 'groq' | 'openrouter' | 'generic'; +export type LlmProviderName = 'ollama' | 'groq' | 'openrouter' | 'novita' | 'generic'; export interface ProviderCredentialOverrides { model?: string; @@ -84,6 +84,19 @@ export function getProviderCredentials( }; } + if (provider === 'novita') { + const apiKey = process.env.NOVITA_API_KEY; + if (!apiKey) return null; + return { + apiUrl: 'https://api.novita.ai/openai/v1/chat/completions', + model: overrides.model || 'moonshotai/kimi-k2.5', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }; + } + // Generic OpenAI-compatible endpoint via LLM_API_URL/LLM_API_KEY/LLM_MODEL if (provider === 'generic') { const apiUrl = process.env.LLM_API_URL; @@ -124,7 +137,7 @@ export function stripThinkingTags(text: string): string { } -const PROVIDER_CHAIN = ['ollama', 'groq', 'openrouter', 'generic'] as const; +const PROVIDER_CHAIN = ['ollama', 'groq', 'openrouter', 'novita', 'generic'] as const; const PROVIDER_SET = new Set(PROVIDER_CHAIN); export interface LlmCallOptions { diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 07f82e7b31..ed6b18bc77 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -136,7 +136,7 @@ globalThis.fetch = async function ipv4Fetch(input, init) { }; const ALLOWED_ENV_KEYS = new Set([ - 'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'EXA_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY', + 'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'NOVITA_API_KEY', 'EXA_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY', 'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY', 'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL', 'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET', @@ -1181,6 +1181,12 @@ async function dispatch(requestUrl, req, routes, context) { probeOrigin('https://openrouter.ai').then((available) => ({ name: 'openrouter', url: 'https://openrouter.ai', available })), ); } + const novitaKey = process.env.NOVITA_API_KEY; + if (novitaKey) { + providerChecks.push( + probeOrigin('https://api.novita.ai').then((available) => ({ name: 'novita', url: 'https://api.novita.ai', available })), + ); + } if (providerChecks.length > 0) { providers.push(...(await Promise.all(providerChecks))); } diff --git a/tests/shared-llm.test.mts b/tests/shared-llm.test.mts index 3c01e6cbce..7dda595635 100644 --- a/tests/shared-llm.test.mts +++ b/tests/shared-llm.test.mts @@ -6,6 +6,7 @@ import { callLlm } from '../server/_shared/llm.ts'; const originalFetch = globalThis.fetch; const originalGroqApiKey = process.env.GROQ_API_KEY; const originalOpenRouterApiKey = process.env.OPENROUTER_API_KEY; +const originalNovitaApiKey = process.env.NOVITA_API_KEY; const originalOllamaApiUrl = process.env.OLLAMA_API_URL; const originalLlmApiUrl = process.env.LLM_API_URL; const originalLlmApiKey = process.env.LLM_API_KEY; @@ -19,6 +20,9 @@ afterEach(() => { if (originalOpenRouterApiKey === undefined) delete process.env.OPENROUTER_API_KEY; else process.env.OPENROUTER_API_KEY = originalOpenRouterApiKey; + if (originalNovitaApiKey === undefined) delete process.env.NOVITA_API_KEY; + else process.env.NOVITA_API_KEY = originalNovitaApiKey; + if (originalOllamaApiUrl === undefined) delete process.env.OLLAMA_API_URL; else process.env.OLLAMA_API_URL = originalOllamaApiUrl; @@ -163,4 +167,84 @@ describe('callLlm', () => { 'https://api.groq.com/openai/v1/chat/completions', ]); }); + + it('supports novita provider with default model', async () => { + process.env.NOVITA_API_KEY = 'novita-test-key'; + delete process.env.GROQ_API_KEY; + delete process.env.OPENROUTER_API_KEY; + delete process.env.OLLAMA_API_URL; + delete process.env.LLM_API_URL; + delete process.env.LLM_API_KEY; + + const postBodies: Array<{ url: string; body: Record }> = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + if ((init?.method || 'GET') === 'GET') { + return new Response('', { status: 200 }); + } + + const body = JSON.parse(String(init?.body || '{}')) as Record; + postBodies.push({ url, body }); + + return new Response(JSON.stringify({ + choices: [{ message: { content: 'novita response' } }], + usage: { total_tokens: 50 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await callLlm({ + messages: [{ role: 'user', content: 'Test Novita integration.' }], + }); + + assert.ok(result); + assert.equal(result.provider, 'novita'); + assert.equal(result.model, 'moonshotai/kimi-k2.5'); + assert.equal(postBodies.length, 1); + assert.equal(postBodies[0]?.url, 'https://api.novita.ai/openai/v1/chat/completions'); + assert.equal(postBodies[0]?.body.model, 'moonshotai/kimi-k2.5'); + }); + + it('supports novita provider with model override', async () => { + process.env.NOVITA_API_KEY = 'novita-test-key'; + delete process.env.GROQ_API_KEY; + delete process.env.OPENROUTER_API_KEY; + delete process.env.OLLAMA_API_URL; + delete process.env.LLM_API_URL; + delete process.env.LLM_API_KEY; + + const postBodies: Array<{ url: string; body: Record }> = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + if ((init?.method || 'GET') === 'GET') { + return new Response('', { status: 200 }); + } + + const body = JSON.parse(String(init?.body || '{}')) as Record; + postBodies.push({ url, body }); + + return new Response(JSON.stringify({ + choices: [{ message: { content: 'novita glm response' } }], + usage: { total_tokens: 75 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await callLlm({ + messages: [{ role: 'user', content: 'Use GLM model.' }], + providerOrder: ['novita'], + modelOverrides: { + novita: 'zai-org/glm-5', + }, + }); + + assert.ok(result); + assert.equal(result.provider, 'novita'); + assert.equal(result.model, 'zai-org/glm-5'); + assert.equal(postBodies.length, 1); + assert.equal(postBodies[0]?.url, 'https://api.novita.ai/openai/v1/chat/completions'); + assert.equal(postBodies[0]?.body.model, 'zai-org/glm-5'); + }); }); From 34ba63fba5e4733ac8cbf57ccb3b22574c5561c3 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Tue, 31 Mar 2026 14:16:08 +0800 Subject: [PATCH 2/2] chore: add NOVITA_API_KEY to .env.example and align variable declaration style Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 ++++ src-tauri/sidecar/local-api-server.mjs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 21a74f820e..770940e6d9 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ # Get yours at: https://console.groq.com/ GROQ_API_KEY= +# Novita AI API (OpenAI-compatible, wide model selection) +# Get yours at: https://novita.ai/ +NOVITA_API_KEY= + # OpenRouter API (fallback — 50 req/day on free tier) # Get yours at: https://openrouter.ai/ OPENROUTER_API_KEY= diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index ed6b18bc77..0caf2e3487 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1162,6 +1162,7 @@ async function dispatch(requestUrl, req, routes, context) { const ollamaUrl = process.env.OLLAMA_API_URL || process.env.LLM_API_URL; const groqKey = process.env.GROQ_API_KEY; const openrouterKey = process.env.OPENROUTER_API_KEY; + const novitaKey = process.env.NOVITA_API_KEY; if (ollamaUrl) { try { @@ -1181,7 +1182,6 @@ async function dispatch(requestUrl, req, routes, context) { probeOrigin('https://openrouter.ai').then((available) => ({ name: 'openrouter', url: 'https://openrouter.ai', available })), ); } - const novitaKey = process.env.NOVITA_API_KEY; if (novitaKey) { providerChecks.push( probeOrigin('https://api.novita.ai').then((available) => ({ name: 'novita', url: 'https://api.novita.ai', available })),