Skip to content

Commit 8f1a338

Browse files
authored
feat: Free Models page with provider logos and public API (#1608)
* feat: add Free Models page with provider logos and public API endpoint - Add Free Models page syncing provider data from awesome-free-llm-apis GitHub repo (daily cron at midnight + on startup) - Add provider logos for all 15 free providers (SVG icons with dark mode variants) - Add CUSTOM_PROVIDER_LOGOS map in ProviderIcon.tsx for icon resolution across Routing, Overview, and Messages pages - Add public endpoint GET /api/v1/public/free-providers (no auth required) - Connect button links to built-in provider modal for known providers (Gemini, Mistral, OpenRouter, Z AI, Ollama Cloud) - Ollama Cloud connect button disabled in cloud mode with tooltip - Rename "Z AI (Zhipu AI)" display name to "Z AI" * test: improve FreeModels coverage to 100% lines - Add test for dark mode logo variants - Add test for disabled Ollama Cloud button in cloud mode - Add test for built-in provider connect links - Add test for show models toggle on providers without base_url
1 parent 6d60e55 commit 8f1a338

36 files changed

+1604
-367
lines changed

.changeset/loose-ducks-happen.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ coverage/
2626
.windsurf/
2727
/backend/
2828
.gstack/
29+
scripts/.env.verify
30+
scripts/verify-free-model-ids.sh

packages/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { SseModule } from './sse/sse.module';
2424
import { GithubModule } from './github/github.module';
2525
import { PublicStatsModule } from './public-stats/public-stats.module';
2626
import { SetupModule } from './setup/setup.module';
27+
import { FreeModelsModule } from './free-models/free-models.module';
2728

2829
const frontendPath = resolveFrontendDir();
2930
const ONE_YEAR_S = 365 * 24 * 60 * 60;
@@ -72,6 +73,7 @@ const serveStaticImports = frontendPath
7273
GithubModule,
7374
PublicStatsModule,
7475
SetupModule,
76+
FreeModelsModule,
7577
],
7678
providers: [
7779
{ provide: APP_GUARD, useClass: SessionGuard },

packages/backend/src/common/constants/cache.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export const DASHBOARD_CACHE_TTL_MS = 30_000;
22
export const AGENT_LIST_CACHE_TTL_MS = 60_000;
33
export const MODEL_PRICES_CACHE_TTL_MS = 300_000;
44
export const PUBLIC_STATS_CACHE_TTL_MS = 86_400_000;
5+
export const FREE_MODELS_CACHE_TTL_MS = 3_600_000;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
export interface ProviderMetadata {
2+
/** Path to self-hosted icon, e.g. '/icons/cohere.svg' */
3+
logo: string;
4+
/** Override the display name from GitHub data.json */
5+
displayName?: string;
6+
/** Bullet-point tags shown under the provider name */
7+
tags: string[];
8+
/** Yellow warning banner text */
9+
warning: string;
10+
}
11+
12+
/**
13+
* UI-specific metadata keyed by the exact `name` from GitHub data.json.
14+
* baseUrl and model IDs come from GitHub — this map only holds
15+
* display/UX fields that don't belong in the shared data source.
16+
*/
17+
export const PROVIDER_METADATA: Readonly<Record<string, ProviderMetadata>> = {
18+
Cerebras: {
19+
logo: '/icons/cerebras.svg',
20+
tags: ['Fast inference', 'No credit card required'],
21+
warning: '',
22+
},
23+
'Cloudflare Workers AI': {
24+
logo: '/icons/workersai.svg',
25+
tags: ['10,000 neurons/day free', 'No credit card required'],
26+
warning: '',
27+
},
28+
Cohere: {
29+
logo: '/icons/cohere.svg',
30+
tags: ['Up to 1,000 calls/month', 'No credit card required'],
31+
warning:
32+
'Trial keys cannot be used for production or commercial workloads. Data may be used for training.',
33+
},
34+
'GitHub Models': {
35+
logo: '/icons/github.svg',
36+
tags: ['Rate-limited free tier', 'No credit card required'],
37+
warning: '',
38+
},
39+
'Google Gemini': {
40+
logo: '/icons/gemini.svg',
41+
tags: ['250K TPM (Tokens / Minute) shared across models', 'No credit card required'],
42+
warning:
43+
'Rate limits apply per Google Cloud project, not per API key. On the free tier, prompts and responses may be used to improve Google products.',
44+
},
45+
Groq: {
46+
logo: '/icons/groq.svg',
47+
tags: ['Fast inference', 'No credit card required'],
48+
warning: '',
49+
},
50+
'Hugging Face': {
51+
logo: '/icons/huggingface.svg',
52+
tags: ['Serverless Inference API', 'No credit card required'],
53+
warning: '',
54+
},
55+
'Kilo Code': {
56+
logo: '/icons/kilocode.jpg',
57+
tags: ['No credit card required'],
58+
warning: 'Prompts and outputs are logged on free models to improve provider products.',
59+
},
60+
'LLM7.io': {
61+
logo: '',
62+
tags: ['No credit card required'],
63+
warning: '',
64+
},
65+
'Mistral AI': {
66+
logo: '/icons/providers/mistral.svg',
67+
tags: ['No credit card required'],
68+
warning: '',
69+
},
70+
'NVIDIA NIM': {
71+
logo: '/icons/nvidia.svg',
72+
tags: ['1,000 free credits', 'No credit card required'],
73+
warning: '',
74+
},
75+
'Ollama Cloud': {
76+
logo: '/icons/ollama.svg',
77+
tags: ['No credit card required'],
78+
warning: '',
79+
},
80+
OpenRouter: {
81+
logo: '/icons/openrouter.svg',
82+
tags: ['Free tier models available', 'No credit card required'],
83+
warning: '',
84+
},
85+
SiliconFlow: {
86+
logo: '/icons/siliconflow.svg',
87+
tags: ['No credit card required'],
88+
warning: '',
89+
},
90+
'Z AI (Zhipu AI)': {
91+
logo: '/icons/zai.svg',
92+
displayName: 'Z AI',
93+
tags: ['No credit card required'],
94+
warning: '',
95+
},
96+
};
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { FreeModelsSyncService } from './free-models-sync.service';
2+
3+
const GITHUB_RAW_URL =
4+
'https://raw.githubusercontent.com/mnfst/awesome-free-llm-apis/main/data.json';
5+
6+
const sampleData = {
7+
lastUpdated: '2026-04-17',
8+
providers: [
9+
{
10+
name: 'Cohere',
11+
category: 'provider_api',
12+
country: 'CA',
13+
flag: '\u{1F1E8}\u{1F1E6}',
14+
url: 'https://dashboard.cohere.com/api-keys',
15+
baseUrl: 'https://api.cohere.ai/compatibility/v1',
16+
description: 'Free trial API key.',
17+
footnoteRef: null,
18+
models: [
19+
{
20+
id: 'command-a-03-2025',
21+
name: 'Command A (111B)',
22+
context: '256K',
23+
maxOutput: '4K',
24+
modality: 'Text',
25+
rateLimit: '20 RPM',
26+
},
27+
],
28+
},
29+
{
30+
name: 'Google Gemini',
31+
category: 'provider_api',
32+
country: 'US',
33+
flag: '\u{1F1FA}\u{1F1F8}',
34+
url: 'https://aistudio.google.com/app/apikey',
35+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/',
36+
description: 'Free tier.',
37+
footnoteRef: 1,
38+
models: [
39+
{
40+
id: 'gemini-2.5-flash',
41+
name: 'Gemini 2.5 Flash',
42+
context: '1M',
43+
maxOutput: '65K',
44+
modality: 'Text + Image',
45+
rateLimit: '10 RPM',
46+
},
47+
],
48+
},
49+
],
50+
};
51+
52+
let fetchSpy: jest.SpyInstance;
53+
54+
describe('FreeModelsSyncService', () => {
55+
let service: FreeModelsSyncService;
56+
57+
beforeEach(() => {
58+
service = new FreeModelsSyncService();
59+
fetchSpy = jest.spyOn(global, 'fetch');
60+
});
61+
62+
afterEach(() => {
63+
fetchSpy.mockRestore();
64+
});
65+
66+
describe('onModuleInit', () => {
67+
it('calls refreshCache on init', async () => {
68+
fetchSpy.mockResolvedValue({
69+
ok: true,
70+
json: async () => sampleData,
71+
});
72+
73+
await service.onModuleInit();
74+
expect(fetchSpy).toHaveBeenCalledWith(GITHUB_RAW_URL);
75+
expect(service.getAll()).toHaveLength(2);
76+
});
77+
78+
it('does not throw when refreshCache rejects', async () => {
79+
jest.spyOn(service, 'refreshCache').mockRejectedValue(new Error('Unexpected'));
80+
await service.onModuleInit();
81+
});
82+
});
83+
84+
describe('refreshCache', () => {
85+
it('populates cache with successful response', async () => {
86+
fetchSpy.mockResolvedValue({
87+
ok: true,
88+
json: async () => sampleData,
89+
});
90+
91+
const count = await service.refreshCache();
92+
expect(count).toBe(2);
93+
expect(service.getAll()).toHaveLength(2);
94+
expect(service.getAll()[0].name).toBe('Cohere');
95+
expect(service.getAll()[1].name).toBe('Google Gemini');
96+
expect(service.getLastFetchedAt()).toBeInstanceOf(Date);
97+
});
98+
99+
it('returns 0 when API returns non-OK status', async () => {
100+
fetchSpy.mockResolvedValue({ ok: false, status: 503 });
101+
102+
const count = await service.refreshCache();
103+
expect(count).toBe(0);
104+
expect(service.getAll()).toHaveLength(0);
105+
});
106+
107+
it('returns 0 when fetch throws', async () => {
108+
fetchSpy.mockRejectedValue(new Error('Network error'));
109+
110+
const count = await service.refreshCache();
111+
expect(count).toBe(0);
112+
expect(service.getAll()).toHaveLength(0);
113+
});
114+
115+
it('returns 0 when providers array is missing', async () => {
116+
fetchSpy.mockResolvedValue({
117+
ok: true,
118+
json: async () => ({ lastUpdated: '2026-04-17' }),
119+
});
120+
121+
const count = await service.refreshCache();
122+
expect(count).toBe(0);
123+
});
124+
125+
it('keeps stale cache when fetch fails after previous success', async () => {
126+
fetchSpy.mockResolvedValue({
127+
ok: true,
128+
json: async () => sampleData,
129+
});
130+
await service.refreshCache();
131+
expect(service.getAll()).toHaveLength(2);
132+
133+
fetchSpy.mockRejectedValue(new Error('Network error'));
134+
const count = await service.refreshCache();
135+
expect(count).toBe(0);
136+
// Stale cache preserved
137+
expect(service.getAll()).toHaveLength(2);
138+
});
139+
140+
it('replaces entire cache on successful refresh', async () => {
141+
fetchSpy.mockResolvedValue({
142+
ok: true,
143+
json: async () => sampleData,
144+
});
145+
await service.refreshCache();
146+
expect(service.getAll()).toHaveLength(2);
147+
148+
fetchSpy.mockResolvedValue({
149+
ok: true,
150+
json: async () => ({
151+
lastUpdated: '2026-04-18',
152+
providers: [sampleData.providers[0]],
153+
}),
154+
});
155+
await service.refreshCache();
156+
expect(service.getAll()).toHaveLength(1);
157+
});
158+
});
159+
160+
describe('getAll', () => {
161+
it('returns empty array initially', () => {
162+
expect(service.getAll()).toEqual([]);
163+
});
164+
});
165+
166+
describe('getLastFetchedAt', () => {
167+
it('returns null before any refresh', () => {
168+
expect(service.getLastFetchedAt()).toBeNull();
169+
});
170+
171+
it('does not update when fetch fails', async () => {
172+
fetchSpy.mockRejectedValue(new Error('fail'));
173+
await service.refreshCache();
174+
expect(service.getLastFetchedAt()).toBeNull();
175+
});
176+
177+
it('does not update when API returns non-OK', async () => {
178+
fetchSpy.mockResolvedValue({ ok: false, status: 500 });
179+
await service.refreshCache();
180+
expect(service.getLastFetchedAt()).toBeNull();
181+
});
182+
});
183+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
4+
export interface GitHubModel {
5+
id: string | null;
6+
name: string;
7+
context: string;
8+
maxOutput: string;
9+
modality: string;
10+
rateLimit: string;
11+
}
12+
13+
export interface GitHubProvider {
14+
name: string;
15+
category: string;
16+
country: string;
17+
flag: string;
18+
url: string;
19+
baseUrl: string | null;
20+
description: string;
21+
footnoteRef: number | null;
22+
models: GitHubModel[];
23+
}
24+
25+
interface GitHubDataJson {
26+
lastUpdated: string;
27+
providers: GitHubProvider[];
28+
}
29+
30+
const GITHUB_RAW_URL =
31+
'https://raw.githubusercontent.com/mnfst/awesome-free-llm-apis/main/data.json';
32+
33+
@Injectable()
34+
export class FreeModelsSyncService implements OnModuleInit {
35+
private readonly logger = new Logger(FreeModelsSyncService.name);
36+
private cache: GitHubProvider[] = [];
37+
private lastFetchedAt: Date | null = null;
38+
39+
async onModuleInit(): Promise<void> {
40+
try {
41+
await this.refreshCache();
42+
} catch (err) {
43+
this.logger.error(`Startup free models sync failed: ${err}`);
44+
}
45+
}
46+
47+
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
48+
async refreshCache(): Promise<number> {
49+
this.logger.log('Refreshing free models cache from GitHub...');
50+
const data = await this.fetchData();
51+
if (!data) return 0;
52+
53+
this.cache = data.providers;
54+
this.lastFetchedAt = new Date();
55+
this.logger.log(`Free models cache loaded: ${data.providers.length} providers`);
56+
return data.providers.length;
57+
}
58+
59+
getAll(): readonly GitHubProvider[] {
60+
return this.cache;
61+
}
62+
63+
getLastFetchedAt(): Date | null {
64+
return this.lastFetchedAt;
65+
}
66+
67+
private async fetchData(): Promise<GitHubDataJson | null> {
68+
try {
69+
const res = await fetch(GITHUB_RAW_URL);
70+
if (!res.ok) {
71+
this.logger.error(`GitHub raw returned ${res.status}`);
72+
return null;
73+
}
74+
const body = (await res.json()) as GitHubDataJson;
75+
if (!Array.isArray(body.providers)) {
76+
this.logger.error('GitHub data.json missing providers array');
77+
return null;
78+
}
79+
return body;
80+
} catch (err) {
81+
this.logger.error(`Failed to fetch free models data: ${err}`);
82+
return null;
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)