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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ GROK_API_KEY=
GROK_BASE_URL=
GROK_MODELS=

# --- Ollama (Local Models) ---------------------------------------------------
# No API key needed. Configure BASE_URL here (server-side) so it bypasses SSRF
# protection automatically. Client-supplied localhost URLs are blocked in production.
# OLLAMA_BASE_URL=http://localhost:11434/v1
# OLLAMA_MODELS=llama3.3,llama3.2,qwen2.5,mistral,gemma3

# --- TTS (Text-to-Speech) ----------------------------------------------------

TTS_OPENAI_API_KEY=
Expand Down
10 changes: 7 additions & 3 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { NextRequest } from 'next/server';
import { statelessGenerate } from '@/lib/orchestration/stateless-generate';
import { isProviderKeyRequired } from '@/lib/ai/providers';
import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
import type { ThinkingConfig } from '@/lib/types/provider';
import { apiError } from '@/lib/server/api-response';
Expand Down Expand Up @@ -63,15 +64,18 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds');
}

const { model: languageModel, apiKey: resolvedApiKey } = resolveModel({
const {
model: languageModel,
apiKey: resolvedApiKey,
providerId,
} = resolveModel({
modelString: body.model,
apiKey: body.apiKey,
baseUrl: body.baseUrl,
providerType: body.providerType,
requiresApiKey: body.requiresApiKey,
});

if (!resolvedApiKey && body.requiresApiKey !== false) {
if (isProviderKeyRequired(providerId) && !resolvedApiKey) {
return apiError('MISSING_API_KEY', 401, 'API Key is required');
}

Expand Down
3 changes: 1 addition & 2 deletions app/api/verify-model/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function POST(req: NextRequest) {
let model: string | undefined;
try {
const body = await req.json();
const { apiKey, baseUrl, providerType, requiresApiKey } = body;
const { apiKey, baseUrl, providerType } = body;
model = body.model;

if (!model) {
Expand All @@ -24,7 +24,6 @@ export async function POST(req: NextRequest) {
apiKey: apiKey || '',
baseUrl: baseUrl || undefined,
providerType,
requiresApiKey,
});
languageModel = result.model;
} catch (error) {
Expand Down
1 change: 0 additions & 1 deletion app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ function GenerationPreviewContent() {
'x-api-key': modelConfig.apiKey,
'x-base-url': modelConfig.baseUrl,
'x-provider-type': modelConfig.providerType || '',
'x-requires-api-key': modelConfig.requiresApiKey ? 'true' : 'false',
// Image generation provider
'x-image-provider': settings.imageProviderId || '',
'x-image-model': settings.imageModelId || '',
Expand Down
1 change: 0 additions & 1 deletion components/scene-renderers/pbl/use-pbl-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export function usePBLChat({ projectConfig, userRole, onConfigUpdate }: UsePBLCh
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true';

// Strip @mention prefix from message text if present
const cleanMessage = text.replace(/^@\w+\s*/i, '').trim() || text;
Expand Down
1 change: 0 additions & 1 deletion components/scene-renderers/quiz-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ async function gradeShortAnswerQuestion(
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
if (modelConfig.requiresApiKey) headers['x-requires-api-key'] = 'true';

const res = await fetch('/api/quiz-grade', {
method: 'POST',
Expand Down
81 changes: 76 additions & 5 deletions lib/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,73 @@ export const PROVIDERS: Record<ProviderId, ProviderConfig> = {
},
],
},

ollama: {
id: 'ollama',
name: 'Ollama',
type: 'openai',
defaultBaseUrl: 'http://localhost:11434/v1',
requiresApiKey: false,
icon: '/logos/ollama.svg',
models: [
{
id: 'llama3.3',
name: 'Llama 3.3 70B',
contextWindow: 131072,
outputWindow: 4096,
capabilities: { streaming: true, tools: true, vision: false },
},
{
id: 'llama3.2',
name: 'Llama 3.2 3B',
contextWindow: 131072,
outputWindow: 4096,
capabilities: { streaming: true, tools: true, vision: false },
},
{
id: 'qwen2.5',
name: 'Qwen 2.5 7B',
contextWindow: 131072,
outputWindow: 8192,
capabilities: { streaming: true, tools: true, vision: false },
},
{
id: 'qwen2.5:32b',
name: 'Qwen 2.5 32B',
contextWindow: 131072,
outputWindow: 8192,
capabilities: { streaming: true, tools: true, vision: false },
},
{
id: 'mistral',
name: 'Mistral 7B',
contextWindow: 32768,
outputWindow: 4096,
capabilities: { streaming: true, tools: false, vision: false },
},
{
id: 'gemma3',
name: 'Gemma 3 12B',
contextWindow: 131072,
outputWindow: 8192,
capabilities: { streaming: true, tools: true, vision: true },
},
{
id: 'deepseek-r1',
name: 'DeepSeek R1',
contextWindow: 131072,
outputWindow: 8192,
capabilities: { streaming: true, tools: false, vision: false },
},
{
id: 'phi4',
name: 'Phi-4 14B',
contextWindow: 16384,
outputWindow: 4096,
capabilities: { streaming: true, tools: false, vision: false },
},
],
},
};

/**
Expand Down Expand Up @@ -1054,20 +1121,25 @@ function normalizeMiniMaxAnthropicBaseUrl(
return `${trimmed}/anthropic/v1`;
}

/** Returns true if the provider requires an API key (defaults to true for unknown providers). */
export function isProviderKeyRequired(providerId: string): boolean {
return getProviderConfig(providerId as ProviderId)?.requiresApiKey ?? true;
}

/**
* Get a configured language model instance with its info
* Accepts individual parameters for flexibility and security
*/
export function getModel(config: ModelConfig): ModelWithInfo {
// Get provider type and requiresApiKey, with fallback to registry
// providerType can come from client for custom providers; fall back to registry.
// requiresApiKey: registry is authoritative; config.requiresApiKey only for custom providers not in registry.
let providerType = config.providerType;
let requiresApiKey = config.requiresApiKey ?? true;
const provider = getProviderConfig(config.providerId);
const requiresApiKey = provider?.requiresApiKey ?? config.requiresApiKey ?? true;

if (!providerType) {
const provider = getProviderConfig(config.providerId);
if (provider) {
providerType = provider.type;
requiresApiKey = provider.requiresApiKey;
} else {
throw new Error(`Unknown provider: ${config.providerId}. Please provide providerType.`);
}
Expand All @@ -1082,7 +1154,6 @@ export function getModel(config: ModelConfig): ModelWithInfo {
const effectiveApiKey = config.apiKey || '';

// Resolve base URL: explicit > provider default > SDK default
const provider = getProviderConfig(config.providerId);
const effectiveBaseUrl = normalizeMiniMaxAnthropicBaseUrl(
config.providerId,
config.baseUrl || provider?.defaultBaseUrl || undefined,
Expand Down
1 change: 0 additions & 1 deletion lib/hooks/use-scene-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ function getApiHeaders(): HeadersInit {
'x-api-key': config.apiKey || '',
'x-base-url': config.baseUrl || '',
'x-provider-type': config.providerType || '',
'x-requires-api-key': String(config.requiresApiKey ?? false),
// Image generation provider
'x-image-provider': settings.imageProviderId || '',
'x-image-model': settings.imageModelId || '',
Expand Down
4 changes: 4 additions & 0 deletions lib/i18n/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export const settingsZhCN = {
minimax: 'MiniMax',
glm: 'GLM',
siliconflow: '硅基流动',
doubao: '豆包',
ollama: 'Ollama(本地模型)',
},
providerTypes: {
openai: 'OpenAI 协议',
Expand Down Expand Up @@ -684,6 +686,8 @@ export const settingsEnUS = {
minimax: 'MiniMax',
glm: 'GLM',
siliconflow: 'SiliconFlow',
doubao: 'Doubao',
ollama: 'Ollama (Local)',
},
providerTypes: {
openai: 'OpenAI Protocol',
Expand Down
10 changes: 4 additions & 6 deletions lib/server/classroom-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import type { AgentInfo } from '@/lib/generation/pipeline-types';
import { formatTeacherPersonaForPrompt } from '@/lib/generation/prompt-formatters';
import { getDefaultAgents } from '@/lib/orchestration/registry/store';
import { createLogger } from '@/lib/logger';
import { parseModelString } from '@/lib/ai/providers';
import { resolveApiKey, resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { isProviderKeyRequired } from '@/lib/ai/providers';
import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { resolveModel } from '@/lib/server/resolve-model';
import { buildSearchQuery } from '@/lib/server/search-query-builder';
import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
Expand Down Expand Up @@ -177,13 +177,11 @@ export async function generateClassroom(
scenesGenerated: 0,
});

const { model: languageModel, modelInfo, modelString } = resolveModel({});
const { model: languageModel, modelInfo, modelString, providerId, apiKey } = resolveModel({});
log.info(`Using server-configured model: ${modelString}`);

// Fail fast if the resolved provider has no API key configured
const { providerId } = parseModelString(modelString);
const apiKey = resolveApiKey(providerId);
if (!apiKey) {
if (isProviderKeyRequired(providerId) && !apiKey) {
throw new Error(
`No API key configured for provider "${providerId}". ` +
`Set the appropriate key in .env.local or server-providers.yml (e.g. ${providerId.toUpperCase()}_API_KEY).`,
Expand Down
6 changes: 3 additions & 3 deletions lib/server/provider-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const LLM_ENV_MAP: Record<string, string> = {
SILICONFLOW: 'siliconflow',
DOUBAO: 'doubao',
GROK: 'grok',
OLLAMA: 'ollama',
};

const TTS_ENV_MAP: Record<string, string> = {
Expand Down Expand Up @@ -134,9 +135,7 @@ function loadEnvSection(
// First, add everything from YAML as defaults
if (yamlSection) {
for (const [id, entry] of Object.entries(yamlSection)) {
const hasKey = !!entry?.apiKey;
const hasUrl = !!entry?.baseUrl;
if (requiresBaseUrl ? hasUrl : hasKey) {
if (requiresBaseUrl ? !!entry?.baseUrl : entry?.apiKey || entry?.baseUrl) {
result[id] = {
apiKey: entry.apiKey || '',
baseUrl: entry.baseUrl,
Expand Down Expand Up @@ -167,6 +166,7 @@ function loadEnvSection(
continue;
}

// Activate on API key; or on base URL alone for baseUrl-only sections (PDF)
if (requiresBaseUrl ? !envBaseUrl : !envApiKey) continue;
result[providerId] = {
apiKey: envApiKey || '',
Expand Down
11 changes: 6 additions & 5 deletions lib/server/resolve-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
export interface ResolvedModel extends ModelWithInfo {
/** Original model string (e.g. "openai/gpt-4o-mini") */
modelString: string;
/** Resolved provider ID (e.g. "openai", "ollama") */
providerId: string;
/** Effective API key after server-side fallback resolution */
apiKey: string;
}
Expand All @@ -27,7 +29,6 @@ export function resolveModel(params: {
apiKey?: string;
baseUrl?: string;
providerType?: string;
requiresApiKey?: boolean;
}): ResolvedModel {
const modelString = params.modelString || process.env.DEFAULT_MODEL || 'gpt-4o-mini';
const { providerId, modelId } = parseModelString(modelString);
Expand All @@ -52,23 +53,23 @@ export function resolveModel(params: {
baseUrl,
proxy,
providerType: params.providerType as 'openai' | 'anthropic' | 'google' | undefined,
requiresApiKey: params.requiresApiKey,
});

return { model, modelInfo, modelString, apiKey };
return { model, modelInfo, modelString, providerId, apiKey };
}

/**
* Resolve a language model from standard request headers.
*
* Reads: x-model, x-api-key, x-base-url, x-provider-type, x-requires-api-key
* Reads: x-model, x-api-key, x-base-url, x-provider-type
* Note: requiresApiKey is derived server-side from the provider registry,
* never from client headers, to prevent auth bypass.
*/
export function resolveModelFromHeaders(req: NextRequest): ResolvedModel {
return resolveModel({
modelString: req.headers.get('x-model') || undefined,
apiKey: req.headers.get('x-api-key') || undefined,
baseUrl: req.headers.get('x-base-url') || undefined,
providerType: req.headers.get('x-provider-type') || undefined,
requiresApiKey: req.headers.get('x-requires-api-key') === 'true' ? true : undefined,
});
}
4 changes: 4 additions & 0 deletions lib/server/ssrf-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
*
* Validates URLs to prevent requests to internal/private network addresses.
* Used by any API route that fetches a user-supplied URL server-side.
*
* Note: Server-configured provider URLs (e.g. OLLAMA_BASE_URL) bypass this
* check entirely — they flow through resolveBaseUrl() and are never validated
* here. This guard only applies to client-supplied URLs.
*/

/** Check if hostname is in the 172.16.0.0 - 172.31.255.255 private range */
Expand Down
3 changes: 2 additions & 1 deletion lib/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type BuiltInProviderId =
| 'glm'
| 'siliconflow'
| 'doubao'
| 'grok';
| 'grok'
| 'ollama';

/**
* Provider ID (built-in or custom)
Expand Down
8 changes: 8 additions & 0 deletions public/logos/ollama.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading