Skip to content
Merged
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
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,16 @@ Prefer to bring a key? Set one and skip the connect step:
```bash
export OPENROUTER_API_KEY=... # or VENICE_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY
export XAI_API_KEY=... # Grok Build (grok-build-0.1) — xAI's coding model, native tool-calling
export NOVITA_API_KEY=... # Novita AI's hosted OpenAI-compatible API
```

Novita AI is an opt-in third-party hosted provider. Requests send the selected
model id, prompts/context, generated output, and request metadata to Novita;
your API key authenticates those requests. Review Novita's current
[privacy policy](https://novita.ai/legal/privacy-policy) and
[terms](https://novita.ai/legal/terms-of-service) before using sensitive target
data. T3MP3ST does not claim independent assurance for sensitive workloads.

Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS`
for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and
`T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds.
Expand Down
7 changes: 7 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ export VENICE_API_KEY=...
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export DEEPSEEK_API_KEY=...
export NOVITA_API_KEY=... # third-party hosted OpenAI-compatible API
export LITELLM_BASE_URL=http://localhost:4000/v1
export LITELLM_API_KEY=... # only if your LiteLLM proxy requires one
```

Hosted providers receive the model id, prompts/context, generated output, and
request metadata needed to serve the request. For Novita, review the current
[privacy policy](https://novita.ai/legal/privacy-policy) and
[terms](https://novita.ai/legal/terms-of-service) before sending sensitive
target data; T3MP3ST does not independently certify it for sensitive workloads.

Local/offline example with Ollama:

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5621,6 +5621,7 @@ <h2 class="settings-title">🎛️ Universal API Config</h2>
<option value="venice">Venice · OpenAI-compatible</option>
<option value="openai">OpenAI · OpenAI-compatible</option>
<option value="nanogpt">NanoGPT · OpenAI-compatible</option>
<option value="novita">Novita AI · OpenAI-compatible</option>
<option value="anthropic">Claude · Anthropic Messages API</option>
</select>
</div>
Expand Down Expand Up @@ -9244,6 +9245,7 @@ <h4>${m.name}</h4>
venice: 'https://api.venice.ai/api/v1',
openai: 'https://api.openai.com/v1',
nanogpt: 'https://nano-gpt.com/api/v1',
novita: 'https://api.novita.ai/openai/v1',
anthropic: 'https://api.anthropic.com',
};
function uacProvider() { return document.getElementById('uacProvider')?.value || 'openrouter'; }
Expand Down
7 changes: 7 additions & 0 deletions docsite/t3mp3st-docs/content/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,17 @@ export VENICE_API_KEY=...
export ANTHROPIC_API_KEY=...
export OPENAI_API_KEY=...
export DEEPSEEK_API_KEY=...
export NOVITA_API_KEY=... # third-party hosted OpenAI-compatible API
export LITELLM_BASE_URL=http://localhost:4000/v1
export LITELLM_API_KEY=... # only if your LiteLLM proxy requires one
```

Hosted providers receive the model id, prompts/context, generated output, and
request metadata needed to serve the request. For Novita, review the current
[privacy policy](https://novita.ai/legal/privacy-policy) and
[terms](https://novita.ai/legal/terms-of-service) before sending sensitive
target data; T3MP3ST does not independently certify it for sensitive workloads.

Local/offline example with Ollama:

```bash
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
94 changes: 94 additions & 0 deletions src/__tests__/novita-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, afterEach, vi } 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', () => {
const originalFetch = global.fetch;

afterEach(() => {
delete process.env.NOVITA_API_KEY;
delete process.env.TEMPEST_MODEL_FALLBACK;
global.fetch = originalFetch;
vi.restoreAllMocks();
});

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('requires the provider-specific key instead of reporting an OpenAI credential error', () => {
expect(createNovitaBackbone().validateConfig()).toEqual({
valid: false,
error: expect.stringContaining('NOVITA_API_KEY'),
});
});

it('sends chat to the Novita endpoint with Bearer auth and the configured model', async () => {
const fetchSpy = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
ok: true,
json: async () => ({
model: 'zai-org/glm-5.2',
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
}),
}));
global.fetch = fetchSpy as unknown as typeof fetch;

const response = await createNovitaBackbone(KEY).chat([{ role: 'user', content: 'hello' }]);
expect(response.content).toBe('ok');
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.novita.ai/openai/v1/chat/completions');
expect((init as RequestInit).headers).toMatchObject({
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
});
expect(JSON.parse(String((init as RequestInit).body))).toMatchObject({
model: 'zai-org/glm-5.2',
messages: [{ role: 'user', content: 'hello' }],
});
});

it.each([401, 429])('surfaces HTTP %i without silently changing providers', async (status) => {
const fetchSpy = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
ok: false,
status,
headers: { get: () => null },
text: async () => 'provider error',
}));
global.fetch = fetchSpy as unknown as typeof fetch;

await expect(createNovitaBackbone(KEY).chat([{ role: 'user', content: 'hello' }]))
.rejects.toMatchObject({ status });
expect(fetchSpy).toHaveBeenCalledTimes(status === 401 ? 1 : 3);
for (const [url] of fetchSpy.mock.calls) {
expect(url).toBe('https://api.novita.ai/openai/v1/chat/completions');
}
}, 10_000);

it('keeps cross-provider fallback disabled unless the operator opts in', () => {
process.env.NOVITA_API_KEY = KEY;
expect(config.getLLMConfig('novita').fallbackChain).toEqual([]);
});

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
Loading
Loading