Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Hugging Face — open models via the OpenAI-compatible Inference Providers router.
# HF_TOKEN is canonical; HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN also work.
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Novita AI — OpenAI-compatible API serving open-source and frontier models.
NOVITA_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GROQ_API_KEY=
TOGETHER_API_KEY=
REPLICATE_API_TOKEN=
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/api-key-env-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('API key environment handling hardening', () => {
it('exportConfig redacts every supported provider key slot', () => {
const block = exportConfigBlock();

for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'huggingface', 'nanogpt', 'litellm']) {
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'huggingface', 'nanogpt', 'novita', 'litellm']) {
expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`);
}
});
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/novita-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, afterEach } from 'vitest';
import { config, AVAILABLE_MODELS } from '../config/index.js';
import { createNovitaBackbone } from '../llm/index.js';

const KEY = 'sk_novita-test-key-0123456789';

describe('Novita AI provider wiring', () => {
afterEach(() => { delete process.env.NOVITA_API_KEY; });

it('resolves the Novita base URL, default model, and key from NOVITA_API_KEY', () => {
process.env.NOVITA_API_KEY = KEY;
const cfg = config.getLLMConfig('novita');
expect(cfg.provider).toBe('novita');
expect(cfg.baseUrl).toBe('https://api.novita.ai/openai/v1');
expect(cfg.model).toBe('zai-org/glm-5.2');
expect(cfg.apiKey).toBe(KEY);
expect(`${cfg.baseUrl}/chat/completions`).toBe(
'https://api.novita.ai/openai/v1/chat/completions',
);
});

it('routes through the OpenAI-compatible backbone and validates with a key', () => {
process.env.NOVITA_API_KEY = KEY;
const bb = createNovitaBackbone();
expect(bb.getProvider()).toBe('novita');
expect(bb.validateConfig().valid).toBe(true);
});

it('publishes a non-empty Novita model catalog and configured provider state', () => {
process.env.NOVITA_API_KEY = KEY;
expect(AVAILABLE_MODELS.novita?.length ?? 0).toBeGreaterThan(0);
expect(config.getConfiguredProviders()).toContain('novita');
});
});
15 changes: 15 additions & 0 deletions src/__tests__/provider-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ describe('listProviderModels — OpenAI-compatible providers', () => {
expect((init as { headers: Record<string, string> }).headers.Authorization)
.toBe('Bearer sk-nano-test-key');
});

it('uses Novita canonical /openai/v1/models with Bearer authentication', async () => {
const f = fakeFetch({ data: [{ id: 'zai-org/glm-5.2' }] });
const models = await listProviderModels('novita', {
baseUrl: 'https://api.novita.ai/openai/v1',
apiKey: 'sk_novita-test-key',
fetchImpl: f,
});

expect(models.map((m) => m.id)).toEqual(['zai-org/glm-5.2']);
const [url, init] = f.mock.calls[0];
expect(url).toBe('https://api.novita.ai/openai/v1/models');
expect((init as { headers: Record<string, string> }).headers.Authorization)
.toBe('Bearer sk_novita-test-key');
});
});

describe('listProviderModels — Anthropic wire', () => {
Expand Down
51 changes: 49 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';

type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'local';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'novita' | 'local';

// =============================================================================
// CONFIGURATION SCHEMA
Expand All @@ -30,6 +30,7 @@ export interface TempestSettings {
huggingface?: string;
nanogpt?: string;
litellm?: string;
novita?: string;
local?: string;
};

Expand Down Expand Up @@ -99,6 +100,12 @@ export interface TempestSettings {
defaultModel: string;
};

// Novita AI — OpenAI-compatible API serving open-source and frontier models
novita: {
baseUrl: string;
defaultModel: string;
};

// Codex CLI/account subscription backend
codex: {
command: string;
Expand Down Expand Up @@ -201,6 +208,13 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'minimax/minimax-m2.7',
},

// Novita AI's OpenAI-compatible surface. The OpenAIAdapter appends
// /chat/completions and provider-models appends /models.
novita: {
baseUrl: 'https://api.novita.ai/openai/v1',
defaultModel: 'zai-org/glm-5.2',
},

codex: {
command: 'codex',
defaultModel: 'codex-default',
Expand Down Expand Up @@ -626,6 +640,25 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
],
// Model ids verified live against api.novita.ai/openai/v1/models (2026-08-15).
novita: [
{
id: 'zai-org/glm-5.2',
name: 'GLM-5.2 (Novita)',
provider: 'Novita',
contextWindow: 1048576,
maxOutput: 131072,
capabilities: ['reasoning', 'code', 'analysis', 'complex-tasks', 'agents', 'tools'],
},
{
id: 'deepseek/deepseek-v4-pro-0813',
name: 'DeepSeek V4 Pro 0813 (Novita)',
provider: 'Novita',
contextWindow: 1048576,
maxOutput: 393216,
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'tools'],
},
],
local: [
{
id: 'local-model',
Expand Down Expand Up @@ -832,6 +865,7 @@ class ConfigManager {
deepseek: 'DEEPSEEK_API_KEY',
huggingface: 'HF_TOKEN',
nanogpt: 'NANOGPT_API_KEY',
novita: 'NOVITA_API_KEY',
litellm: 'LITELLM_API_KEY',
};

Expand Down Expand Up @@ -901,6 +935,7 @@ class ConfigManager {
if (this.hasApiKey('deepseek')) providers.push('deepseek');
if (this.hasApiKey('huggingface')) providers.push('huggingface');
if (this.hasApiKey('nanogpt')) providers.push('nanogpt');
if (this.hasApiKey('novita')) providers.push('novita');

// Codex uses the local Codex CLI/account auth instead of API-key storage.
providers.push('codex');
Expand Down Expand Up @@ -986,6 +1021,12 @@ class ConfigManager {
baseUrl = this.config.get('nanogpt').baseUrl;
actualModel = model || this.config.get('nanogpt').defaultModel;
break;
case 'novita':
// Novita AI's OpenAI-compatible surface.
apiKey = this.getApiKey('novita');
baseUrl = this.config.get('novita').baseUrl;
actualModel = model || this.config.get('novita').defaultModel;
break;
case 'codex':
actualModel = model || this.config.get('codex').defaultModel;
break;
Expand Down Expand Up @@ -1052,7 +1093,7 @@ class ConfigManager {
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
const chain: FallbackEntry[] = [];
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt') => {
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'novita') => {
if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({
Expand All @@ -1072,6 +1113,7 @@ class ConfigManager {
add('deepseek');
add('huggingface');
add('nanogpt');
add('novita');
return chain;
}

Expand Down Expand Up @@ -1199,6 +1241,7 @@ class ConfigManager {
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined,
nanogpt: settings.apiKeys.nanogpt ? '***REDACTED***' : undefined,
novita: settings.apiKeys.novita ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
},
};
Expand Down Expand Up @@ -1256,6 +1299,10 @@ HF_TOKEN=
# Docs and key management: https://docs.nano-gpt.com/authentication
NANOGPT_API_KEY=

# Novita AI API Key (OpenAI-compatible API serving open-source and frontier models)
# Get your key at: https://novita.ai/settings/key-management
NOVITA_API_KEY=

# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server)
# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below).
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
Expand Down
2 changes: 1 addition & 1 deletion src/config/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const DEFAULT_MODEL_LIST_TIMEOUT_MS = 15_000;

// Pseudo-providers with no remote model list (CLI-driven or in-process).
const NO_REMOTE_LIST = new Set(['codex', 'mock', 'local-agent']);
const OPENAI_COMPATIBLE_REMOTE_LIST = new Set(['openai', 'venice', 'xai', 'gemini', 'nanogpt', 'local']);
const OPENAI_COMPATIBLE_REMOTE_LIST = new Set(['openai', 'venice', 'xai', 'gemini', 'nanogpt', 'novita', 'local']);
const DIRECT_REMOTE_LIST = new Set(['anthropic', 'openrouter']);

const stripTrailingSlash = (u: string): string => u.replace(/\/+$/, '');
Expand Down
14 changes: 13 additions & 1 deletion src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* - Anthropic (direct Claude access)
* - OpenAI (GPT models)
* - HuggingFace (open models via the OpenAI-compatible Inference Providers router)
* - Novita AI (OpenAI-compatible API serving open-source and frontier models)
* - Mock (for testing)
* - Local (Ollama, etc.)
*/
Expand Down Expand Up @@ -1547,6 +1548,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenAIAdapter(config); // HF Inference Providers router is OpenAI-compatible (baseUrl ends in /v1)
case 'nanogpt':
return new NanoGPTAdapter(config);
case 'novita':
return new OpenAIAdapter(config); // Novita AI's native API is OpenAI-compatible
case 'codex':
return new CodexAdapter(config);
case 'mock':
Expand Down Expand Up @@ -1853,6 +1856,12 @@ export function createNanoGPTBackbone(apiKey?: string, model?: string): LLMBackb
return new LLMBackbone(llmConfig);
}

export function createNovitaBackbone(apiKey?: string, model?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('novita', model);
if (apiKey) llmConfig.apiKey = apiKey;
return new LLMBackbone(llmConfig);
}

export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('litellm', model);
if (apiKey) llmConfig.apiKey = apiKey;
Expand Down Expand Up @@ -1883,7 +1892,7 @@ export function createLocalBackbone(model?: string, baseUrl?: string): LLMBackbo
* Create the best available backbone based on configured API keys
*/
export function createBestAvailableBackbone(): LLMBackbone {
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > HuggingFace > NanoGPT > Local > Mock
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > HuggingFace > NanoGPT > Novita > Local > Mock
const providers = config.getConfiguredProviders();

if (providers.includes('openrouter')) {
Expand All @@ -1910,6 +1919,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
if (providers.includes('nanogpt')) {
return createNanoGPTBackbone();
}
if (providers.includes('novita')) {
return createNovitaBackbone();
}

// Default to mock if no API keys configured
console.warn('No API keys configured. Using mock provider.');
Expand Down
55 changes: 55 additions & 0 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,53 @@ async function setupNanoGPTKey(): Promise<boolean> {
}
}

async function setupNovitaKey(): Promise<boolean> {
console.log('');
showInfo('Novita AI provides a direct OpenAI-compatible API serving open-source and frontier models.');
showInfo('Get your API key at: ' + chalk.underline('https://novita.ai/settings/key-management'));
console.log('');

const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'Enter your Novita AI API key:',
mask: '*',
validate: (input: string) => {
if (!input || input.length < 10) {
return 'Please enter a valid API key';
}
return true;
},
},
]);

const spinner = ora('Testing API key...').start();

try {
const llm = new LLMBackbone({
provider: 'novita',
model: 'zai-org/glm-5.2',
baseUrl: 'https://api.novita.ai/openai/v1',
apiKey,
maxTokens: 10,
temperature: 0,
});

await llm.prompt('Hello', undefined, { maxTokens: 10 });
spinner.succeed('API key is valid!');

setApiKey('novita', apiKey);
showSuccess('Novita AI API key saved successfully!');

return true;
} catch (error) {
spinner.fail('API key validation failed');
showError(`Error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}

async function setupOpenAIKey(): Promise<boolean> {
console.log('');
showInfo('OpenAI provides access to GPT models.');
Expand Down Expand Up @@ -627,6 +674,10 @@ async function setupApiKeys(): Promise<void> {
name: `NanoGPT ${hasApiKey('nanogpt') ? chalk.green('(configured)') : chalk.gray('(direct OpenAI-compatible API)')}`,
value: 'nanogpt',
},
{
name: `Novita AI ${hasApiKey('novita') ? chalk.green('(configured)') : chalk.gray('(direct OpenAI-compatible API)')}`,
value: 'novita',
},
{
name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`,
value: 'litellm',
Expand Down Expand Up @@ -658,6 +709,9 @@ async function setupApiKeys(): Promise<void> {
case 'nanogpt':
await setupNanoGPTKey();
break;
case 'novita':
await setupNovitaKey();
break;
case 'litellm':
await setupLiteLLMProxy();
break;
Expand All @@ -676,6 +730,7 @@ async function setupProvider(): Promise<void> {
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
if (hasApiKey('huggingface')) configuredProviders.push({ name: 'HuggingFace', value: 'huggingface' });
if (hasApiKey('nanogpt')) configuredProviders.push({ name: 'NanoGPT', value: 'nanogpt' });
if (hasApiKey('novita')) configuredProviders.push({ name: 'Novita AI', value: 'novita' });
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });

const { provider } = await inquirer.prompt([
Expand Down
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// LLM CONFIGURATION
// =============================================================================

export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'codex' | 'mock' | 'local' | 'local-agent';
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'novita' | 'codex' | 'mock' | 'local' | 'local-agent';

export interface LLMConfig {
provider: LLMProvider;
Expand Down