Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions server/_shared/llm-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions server/_shared/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface ProviderCredentials {
extraBody?: Record<string, unknown>;
}

export type LlmProviderName = 'ollama' | 'groq' | 'openrouter' | 'generic';
export type LlmProviderName = 'ollama' | 'groq' | 'openrouter' | 'novita' | 'generic';

export interface ProviderCredentialOverrides {
model?: string;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>(PROVIDER_CHAIN);

export interface LlmCallOptions {
Expand Down
8 changes: 7 additions & 1 deletion src-tauri/sidecar/local-api-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 {
Expand All @@ -1181,6 +1182,11 @@ async function dispatch(requestUrl, req, routes, context) {
probeOrigin('https://openrouter.ai').then((available) => ({ name: 'openrouter', url: 'https://openrouter.ai', available })),
);
}
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)));
}
Expand Down
84 changes: 84 additions & 0 deletions tests/shared-llm.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, unknown> }> = [];

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<string, unknown>;
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<string, unknown> }> = [];

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<string, unknown>;
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');
});
});