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
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const AUDIT_EXEMPT_ROUTES = new Map<string, string>([
// --- Local model discovery (no user data, no external calls) ---
['ai/ollama/models', 'Local Ollama model discovery, no user data'],
['ai/lmstudio/models', 'Local LMStudio model discovery, no user data'],
['ai/openrouter/models', 'OpenRouter free model discovery — read-only, auth-gated, returns only public model metadata'],

// --- Share link management routes ---
// TODO: Add audit coverage in follow-up PR
Expand Down
293 changes: 293 additions & 0 deletions apps/web/src/app/api/ai/openrouter/models/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NextResponse } from 'next/server';
import { GET } from '../route';
import { filterFreeModels } from '../filter-utils';
import type { SessionAuthResult, AuthError } from '@/lib/auth';

vi.mock('@pagespace/lib/logging/logger-config', () => ({
loggers: {
ai: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
},
logger: { child: vi.fn(() => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })) },
}));

vi.mock('@/lib/auth', () => ({
authenticateSessionRequest: vi.fn(),
isAuthError: vi.fn(),
}));

vi.mock('@/lib/ai/core/ai-utils', () => ({
getManagedProviderKey: vi.fn(),
}));

import { loggers } from '@pagespace/lib/logging/logger-config';
import { authenticateSessionRequest, isAuthError } from '@/lib/auth';
import { getManagedProviderKey } from '@/lib/ai/core/ai-utils';

const mockWebAuth = (userId: string, tokenVersion = 0): SessionAuthResult => ({
userId,
tokenVersion,
tokenType: 'session',
sessionId: 'test-session-id',
role: 'user',
adminRoleVersion: 0,
});

const mockAuthError = (status = 401): AuthError => ({
error: NextResponse.json({ error: 'Unauthorized' }, { status }),
});

const makeFreeModel = (id: string, name: string) => ({
id,
name,
pricing: { prompt: '0', completion: '0' },
});

const makePaidModel = (id: string, name: string) => ({
id,
name,
pricing: { prompt: '0.000001', completion: '0.000002' },
});

// ── Pure function tests ───────────────────────────────────────────────────────

describe('filterFreeModels', () => {
it('should keep models with :free suffix and zero prompt pricing', () => {
const models = [makeFreeModel('meta-llama/llama-3.3-70b-instruct:free', 'Llama 3.3 70B')];
expect(filterFreeModels(models)).toEqual({
'meta-llama/llama-3.3-70b-instruct:free': 'Llama 3.3 70B',
});
});

it('should exclude models without :free suffix even if pricing is zero', () => {
const models = [makeFreeModel('meta-llama/llama-3.3-70b-instruct', 'Llama 3.3 70B')];
expect(filterFreeModels(models)).toEqual({});
});

it('should exclude :free-suffixed models with non-zero prompt pricing', () => {
const models = [makePaidModel('some/model:free', 'Paid Free Model')];
expect(filterFreeModels(models)).toEqual({});
});

it('should exclude models with no pricing field', () => {
const models = [{ id: 'some/model:free', name: 'No Pricing' }];
expect(filterFreeModels(models)).toEqual({});
});

it('should handle a mixed list and return only qualifying models', () => {
const models = [
makeFreeModel('qwen/qwen3-coder:free', 'Qwen3 Coder'),
makePaidModel('openai/gpt-4o', 'GPT-4o'),
makeFreeModel('google/gemma-4-31b-it:free', 'Gemma 4 31B'),
makePaidModel('fake/model:free', 'Fake Paid Free'),
{ id: 'no-pricing:free', name: 'No Pricing' },
];
expect(filterFreeModels(models)).toEqual({
'qwen/qwen3-coder:free': 'Qwen3 Coder',
'google/gemma-4-31b-it:free': 'Gemma 4 31B',
});
});

it('should return empty object for empty input', () => {
expect(filterFreeModels([])).toEqual({});
});
});

// ── Route handler tests ───────────────────────────────────────────────────────

describe('GET /api/ai/openrouter/models', () => {
const mockUserId = 'user_123';
const mockApiKey = 'sk-or-test-key';

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(authenticateSessionRequest).mockResolvedValue(mockWebAuth(mockUserId));
vi.mocked(isAuthError).mockReturnValue(false);
vi.mocked(getManagedProviderKey).mockReturnValue(null);
});

describe('authentication', () => {
it('should return 401 when not authenticated', async () => {
vi.mocked(isAuthError).mockReturnValue(true);
vi.mocked(authenticateSessionRequest).mockResolvedValue(mockAuthError(401));

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
expect(response.status).toBe(401);
});
});

describe('configuration validation', () => {
it('should return 503 when OpenRouter is not configured', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue(null);

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(503);
expect(body.success).toBe(false);
expect(body.error).toContain('not configured');
expect(body.models).toEqual({});
});
});

describe('successful model fetch', () => {
it('should return only free models from OpenRouter', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });

const mockData = {
data: [
makeFreeModel('qwen/qwen3-coder:free', 'Qwen3 Coder'),
makePaidModel('openai/gpt-4o', 'GPT-4o'),
makeFreeModel('google/gemma-4-31b-it:free', 'Gemma 4 31B'),
],
};

global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
});

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.models).toEqual({
'qwen/qwen3-coder:free': 'Qwen3 Coder',
'google/gemma-4-31b-it:free': 'Gemma 4 31B',
});
expect(body.modelCount).toBe(2);
});

it('should call the OpenRouter models endpoint with the managed API key', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });

global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});

await GET(new Request('https://example.com/api/ai/openrouter/models'));

expect(global.fetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/models',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: `Bearer ${mockApiKey}` }),
})
);
});

it('should return empty models when all fetched models are paid', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });

global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makePaidModel('openai/gpt-4o', 'GPT-4o')] }),
});

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.models).toEqual({});
expect(body.modelCount).toBe(0);
});

it('should log successful fetch', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });

global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeFreeModel('qwen/qwen3-coder:free', 'Qwen3 Coder')] }),
});

await GET(new Request('https://example.com/api/ai/openrouter/models'));

expect(loggers.ai.info).toHaveBeenCalledWith(
'Successfully fetched OpenRouter free models',
expect.objectContaining({ userId: mockUserId, modelCount: 1 })
);
});

it('should handle missing data array in response', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });

global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.models).toEqual({});
});
});

describe('fetch failures', () => {
it('should return 200 with empty models when OpenRouter is unreachable', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(false);
expect(body.error).toContain('Could not fetch OpenRouter models');
expect(body.models).toEqual({});
});

it('should return 200 with empty models when OpenRouter returns non-OK status', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(false);
expect(body.models).toEqual({});
});

it('should log fetch errors', async () => {
vi.mocked(getManagedProviderKey).mockReturnValue({ apiKey: mockApiKey });
const error = new Error('Timeout');
global.fetch = vi.fn().mockRejectedValue(error);

await GET(new Request('https://example.com/api/ai/openrouter/models'));

expect(loggers.ai.error).toHaveBeenCalledWith(
'Failed to fetch models from OpenRouter',
error,
expect.objectContaining({ userId: mockUserId })
);
});
});

describe('unexpected errors', () => {
it('should return 500 on unexpected errors', async () => {
vi.mocked(getManagedProviderKey).mockImplementationOnce(() => { throw new Error('DB error'); });

const response = await GET(new Request('https://example.com/api/ai/openrouter/models'));
const body = await response.json();

expect(response.status).toBe(500);
expect(body.success).toBe(false);
expect(body.error).toBe('Failed to fetch OpenRouter models');
expect(body.models).toEqual({});
expect(loggers.ai.error).toHaveBeenCalledWith(
'OpenRouter models fetch error',
expect.objectContaining({ message: 'DB error' })
);
});
});
});
12 changes: 12 additions & 0 deletions apps/web/src/app/api/ai/openrouter/models/filter-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type OpenRouterModel = {
id: string;
name: string;
pricing?: { prompt: string };
};

export const filterFreeModels = (models: OpenRouterModel[]): Record<string, string> =>
Object.fromEntries(
models
.filter(m => m.id.endsWith(':free') && m.pricing?.prompt === '0')
.map(m => [m.id, m.name])
);
54 changes: 54 additions & 0 deletions apps/web/src/app/api/ai/openrouter/models/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { authenticateSessionRequest, isAuthError } from '@/lib/auth';
import { loggers } from '@pagespace/lib/logging/logger-config';
import { getManagedProviderKey } from '@/lib/ai/core/ai-utils';
import { filterFreeModels } from './filter-utils';

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: reduce with spread creates a new object on every iteration (O(n²) allocations). At ~100–300 models this is harmless, but Object.fromEntries is cleaner:

export const filterFreeModels = (models: OpenRouterModel[]): Record<string, string> =>
  Object.fromEntries(
    models
      .filter(m => m.id.endsWith(":free") && m.pricing?.prompt === "0")
      .map(m => [m.id, m.name])
  );

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — switched to Object.fromEntries in filter-utils.ts.

export async function GET(request: Request) {
try {
const auth = await authenticateSessionRequest(request);
if (isAuthError(auth)) return auth.error;
const { userId } = auth;

const settings = getManagedProviderKey('openrouter');
if (!settings?.apiKey) {
return NextResponse.json(
{ success: false, error: 'OpenRouter is not configured on this deployment.', models: {} },
{ status: 503 }
);
}

try {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: { Authorization: `Bearer ${settings.apiKey}` },
});

if (!response.ok) {
throw new Error(`OpenRouter responded with status ${response.status}`);
}

const data = await response.json();
const models = filterFreeModels(Array.isArray(data?.data) ? data.data : []);

loggers.ai.info('Successfully fetched OpenRouter free models', {
userId,
modelCount: Object.keys(models).length,
});

return NextResponse.json({ success: true, models, modelCount: Object.keys(models).length });
} catch (fetchError) {
loggers.ai.error('Failed to fetch models from OpenRouter', fetchError as Error, { userId });

return NextResponse.json(
{ success: false, error: 'Could not fetch OpenRouter models.', models: {} },
{ status: 200 }
);
}
} catch (error) {
loggers.ai.error('OpenRouter models fetch error', error as Error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch OpenRouter models', models: {} },
{ status: 500 }
);
}
}
Loading
Loading