Repository navigation
feat(ai): expose OpenRouter free tier as a user-selectable provider #1397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
da4f211
feat(ai): expose OpenRouter free tier as a user-selectable provider
claude f27b102
feat(ai): dynamic openrouter_free model list
2witstudios c24a048
fix(ai): move filterFreeModels to utils, add :free model enforcement …
2witstudios 26ad266
refactor(ai): use Object.fromEntries, remove vitest workspace alias, …
2witstudios 6147c9a
fix(test): export NextRequest from next-server stub to unblock auth t…
2witstudios 692ed03
fix(test): NextResponse.redirect defaults to 307, not 302
2witstudios 288294f
fix(test): exempt ai/openrouter/models from security audit coverage gate
2witstudios 1faa230
fix(ai): allow openrouter_free provider switch without model
2witstudios 84c6c88
fix(test): properly merge Headers objects in next-server-stub
2witstudios File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
293 changes: 293 additions & 0 deletions
293
apps/web/src/app/api/ai/openrouter/models/__tests__/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }) | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]) | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
|
|
||
| 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 } | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
reducewith spread creates a new object on every iteration (O(n²) allocations). At ~100–300 models this is harmless, butObject.fromEntriesis cleaner:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — switched to
Object.fromEntriesinfilter-utils.ts.