commit 04e15037f24798b64c62df8f8bd8832df2fdcf6b Author: tashfeenahmed Date: Tue Apr 21 20:48:54 2026 +0100 Initial release of FreeLLMAPI Self-hosted OpenAI-compatible proxy that aggregates the free tiers of fourteen LLM providers — Google, Groq, Cerebras, SambaNova, NVIDIA, Mistral, OpenRouter, GitHub Models, Hugging Face, Cohere, Cloudflare, Zhipu, Moonshot, MiniMax — behind a single /v1/chat/completions endpoint. Server: - Express + SQLite, per-provider adapters with streaming and non-streaming support, automatic fallover on 429/5xx, per-key RPM/RPD/TPM/TPD tracking, sticky sessions for multi-turn, AES-256-GCM encrypted key storage, unified bearer-token auth, periodic health checks. Client: - React + Vite + shadcn/ui admin dashboard: keys, fallback chain (drag to reorder, color-coded per-provider monthly token budget), playground, analytics with per-provider breakdowns. Tooling: - GitHub Actions CI (server tests + client build), MIT license, README with provider-by-provider ToS review. For personal experimentation, not production. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d08d9fe --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Server encryption key for API key storage (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") +ENCRYPTION_KEY=your-64-char-hex-key-here + +# Server port (default: 3001) +PORT=3001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28d7fa5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Run server tests + run: npm test -w server + + - name: Build server + run: npm run build -w server + + - name: Build client + run: npm run build -w client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b48033a --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +server/data/ +*.db +*.db-wal +*.db-shm +.env +.env.local +.DS_Store + +# Personal deployment scripts (contain keys/credentials — kept local) +deploy-pi.sh +update-hermes.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8eb2b07 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tashfeen Ahmed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da432dd --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +
+ +# FreeLLMAPI + +**One OpenAI-compatible endpoint. Fourteen free LLM providers. ~800M+ tokens per month.** + +Aggregate the free tiers from Google, Groq, Cerebras, SambaNova, NVIDIA, Mistral, OpenRouter, GitHub Models, Hugging Face, Cohere, Cloudflare, Zhipu, Moonshot, and MiniMax behind a single `/v1/chat/completions` endpoint. Keys are stored encrypted. A router picks the best available model for each request, falls over to the next provider when one is rate-limited, and tracks per-key usage so you stay under every free-tier cap. + +[![CI](https://github.com/tashfeenahmed/freellmapi/actions/workflows/ci.yml/badge.svg)](https://github.com/tashfeenahmed/freellmapi/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) + +![Fallback chain with per-provider token budget](repo-assets/fallback-chain.png) + +
+ +--- + +## Contents + +- [Why this exists](#why-this-exists) +- [Supported providers](#supported-providers) +- [Features](#features) +- [Not yet supported](#not-yet-supported) +- [Quick start](#quick-start) +- [Using the API](#using-the-api) +- [Screenshots](#screenshots) +- [How it works](#how-it-works) +- [Limitations](#limitations) +- [Contributing](#contributing) +- [Terms of Service review](#terms-of-service-review) +- [Disclaimer](#disclaimer) + +## Why this exists + +Every serious AI lab now offers a free tier — a few million tokens a month, a few thousand requests a day. On its own each tier is a toy. Stacked together, they add up to roughly **800 million tokens per month** of working inference capacity, across dozens of models from small-and-fast to reasonably capable. + +The problem is that stacking them by hand is painful: fourteen different SDKs, fourteen different rate limits, fourteen places a request can fail. FreeLLMAPI collapses that into one OpenAI-compatible endpoint. Point any OpenAI client library at your local server, and it routes transparently across whichever providers you've added keys for. + +## Supported providers + + + + + + + + + + + + + + + + + + + + + + + + + +
Google
Gemini 2.5 Pro / Flash
Groq
Llama 4, Qwen, Kimi
Cerebras
Llama 3.3, Qwen
SambaNova
Llama 3.3 70B
NVIDIA
NIM catalog
Mistral
La Plateforme
OpenRouter
Free-tier models
GitHub Models
GPT-4o, Llama, Phi
Hugging Face
Inference Providers
Cohere
Command R+ (trial)
Cloudflare
Workers AI
Zhipu
GLM-4 series
Moonshot
Kimi
MiniMax
abab / hailuo
Adding another? See Contributing.
+ +## Features + +- **OpenAI-compatible** — `POST /v1/chat/completions` and `GET /v1/models` work with the official OpenAI SDKs and any OpenAI-compatible client (LangChain, LlamaIndex, Continue, Hermes, etc.). Just change `base_url`. +- **Streaming and non-streaming** — Server-Sent Events for `stream: true`, JSON response otherwise. Every provider adapter implements both. +- **Automatic fallover** — If the chosen provider returns a 429, 5xx, or times out, the router skips it, puts the key on a short cooldown, and retries on the next model in your fallback chain (up to 20 attempts). +- **Per-key rate tracking** — RPM, RPD, TPM, and TPD counters per `(platform, model, key)` so the router always picks a key that's under its caps. +- **Sticky sessions** — Multi-turn conversations keep talking to the same model for 30 minutes to avoid the hallucination spike that comes from mid-conversation model switches. +- **Encrypted key storage** — API keys are encrypted with AES-256-GCM before hitting SQLite; decryption happens in-memory just before a request. +- **Unified API key** — Clients authenticate to your proxy with a single `freellmapi-…` bearer token. You never expose upstream provider keys to your apps. +- **Health checks** — Periodic probes mark keys as `healthy`, `rate_limited`, `invalid`, or `error` so the router skips dead ones automatically. +- **Admin dashboard** — React + Vite UI to manage keys, reorder the fallback chain, inspect analytics, and run prompts in a playground. Dark mode included. +- **Analytics** — Per-request logging with latency, token counts, success rate, and per-provider breakdowns. +- **Deploys to a Raspberry Pi** — Runs happily on a Pi 4 under PM2 behind nginx. ~40 MB RSS at idle. + +## Not yet supported + +The scope is deliberately narrow. If a feature isn't on this list and isn't below, assume it isn't there yet. + +- **Embeddings** (`/v1/embeddings`) +- **Image generation** (`/v1/images/*`) +- **Audio / speech** (`/v1/audio/*`) +- **Function / tool calling** — the request schema doesn't pass `tools` through yet +- **Vision / multimodal inputs** — message content is text-only +- **Legacy completions** (`/v1/completions`) — only the chat endpoint is implemented +- **Moderation** (`/v1/moderations`) +- **`n > 1`** (multiple completions per request) +- **Per-user billing / multi-tenant auth** — single-user by design + +PRs that add any of these are very welcome. See [Contributing](#contributing). + +## Quick start + +**Prerequisites:** Node.js 20+, npm. + +```bash +git clone https://github.com/tashfeenahmed/freellmapi.git +cd freellmapi +npm install + +# Generate an encryption key for at-rest key storage +cp .env.example .env +echo "ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")" >> .env + +# Start server + dashboard together +npm run dev +``` + +Open http://localhost:5173 (the Vite dev UI), add your provider keys on the **Keys** page, reorder the **Fallback Chain** to taste, and grab your unified API key from the **Keys** page header. That unified key is what you point your OpenAI SDK at. + +For a production build: + +```bash +npm run build +node server/dist/index.js # server + dashboard both served on :3001 +``` + +## Using the API + +Any OpenAI-compatible client works. Examples: + +**Python** + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:3001/v1", + api_key="freellmapi-your-unified-key", +) + +resp = client.chat.completions.create( + model="auto", # let the router pick; or specify e.g. "gemini-2.5-flash" + messages=[{"role": "user", "content": "Summarise the fall of Rome in one sentence."}], +) +print(resp.choices[0].message.content) +print("Routed via:", resp.headers.get("x-routed-via")) +``` + +**curl** + +```bash +curl http://localhost:3001/v1/chat/completions \ + -H "Authorization: Bearer freellmapi-your-unified-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "auto", + "messages": [{"role": "user", "content": "hi"}] + }' +``` + +**Streaming** + +```python +stream = client.chat.completions.create( + model="auto", + messages=[{"role": "user", "content": "Stream me a haiku about SQLite."}], + stream=True, +) +for chunk in stream: + print(chunk.choices[0].delta.content or "", end="", flush=True) +``` + +Every response carries an `X-Routed-Via: /` header so you can see which provider actually served each call. If a request fell over between providers, you'll also see `X-Fallback-Attempts: N`. + +## Screenshots + +### Keys + +Manage provider credentials and grab the unified API key your apps connect with. Each key shows a status dot and when it was last health-checked. + +![Keys page](repo-assets/keys.png) + +### Playground + +Send a chat completion through the router and see which provider served it, with the model ID and latency printed right on the message. + +![Playground page](repo-assets/playground.png) + +### Analytics + +Request volume, success rate, tokens in and out, average latency, and per-provider breakdowns over 24h / 7d / 30d windows. + +![Analytics page](repo-assets/analytics.png) + +## How it works + +``` +┌──────────────────┐ Bearer freellmapi-… ┌─────────────────────────┐ +│ OpenAI SDK / │ ──────────────────────▶ │ Express proxy (:3001) │ +│ curl / any │ ◀────────────────────── │ /v1/chat/completions │ +│ OpenAI client │ streamed tokens └────────────┬────────────┘ +└──────────────────┘ │ + ▼ + ┌────────────────────────────────────────────────┐ + │ Router │ + │ 1. Pick highest-priority model that │ + │ (a) has a healthy key and │ + │ (b) is under all its rate limits. │ + │ 2. Decrypt key, call provider SDK. │ + │ 3. On 429/5xx → cooldown + retry next model. │ + └────────────────────────────────────────────────┘ + │ + ┌──────────────┬────────────┬──────────┴─────────┬─────────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + Google Groq Cerebras OpenRouter HF …10 more +``` + +- **Router** (`server/src/services/router.ts`) — picks a model per request. +- **Rate-limit ledger** (`server/src/services/ratelimit.ts`) — in-memory RPM/RPD/TPM/TPD counters backed by SQLite, with cooldowns on 429s. +- **Provider adapters** (`server/src/providers/*.ts`) — one file per provider, implementing the `Provider` base class: `chatCompletion()` and `streamChatCompletion()`. +- **Health service** (`server/src/services/health.ts`) — periodic probe keeps key status fresh. +- **Dashboard** (`client/`) — React + Vite + shadcn/ui admin surface. +- **Storage** — SQLite (`better-sqlite3`) with AES-256-GCM envelope encryption for keys. + +## Limitations + +Stacking free tiers has real trade-offs. Be honest with yourself about them: + +- **No frontier models.** The free-tier catalog tops out around Llama 3.3 70B, GLM-4.5, Qwen 3 Coder, and Gemini 2.5 Pro. You will not get GPT-5 or Claude Opus class reasoning through this. For hard problems, pay for a real API. +- **Intelligence degrades as the day progresses.** Your top-ranked models (usually Gemini 2.5 Pro, GPT-4o via GitHub Models) have the lowest daily caps. Once they hit their limits, the router falls down your priority chain to smaller/weaker models. Expect the effective intelligence of the endpoint to drop in the late hours of each day — then reset at UTC midnight. +- **Latency is highly variable.** Cerebras and Groq are extremely fast; others are not. You get whichever one is available. +- **Free tiers can change without notice.** Providers regularly tighten, loosen, or remove free tiers. When that happens you'll see 429s or auth errors until you update the catalog. Re-seed scripts live in `server/src/scripts/`. +- **No SLA, by definition.** If you need reliability, use a paid provider with a contract. +- **Local-first.** There's no multi-tenant auth. Run this for yourself; don't expose it to the internet. + +## Contributing + +Contributors very welcome! Good first PRs: + +- **Add a provider** — copy `server/src/providers/openai-compat.ts` as a template, wire it into `server/src/providers/index.ts`, seed its models in `server/src/db/index.ts`, add a test in `server/src/__tests__/providers/`. +- **Add an endpoint** — embeddings, images, moderations. The provider base class can grow new methods; adapters declare which they support. +- **Improve the router** — cost-aware routing (cheapest-healthy-fastest tradeoffs), better latency-weighted priority, regional pinning. +- **Dashboard polish** — charts on the Analytics page, key rotation UX, batch import of keys from `.env`. +- **Docs** — more examples, client library snippets for Go/Rust/etc., a deployment recipe for Docker or Fly. + +**Development loop:** + +```bash +npm install +npm run dev # server on :3001, dashboard on :5173, both with HMR +npm test # vitest — 69 tests across providers, routes, router, ratelimit +``` + +PRs should include a test, keep the existing test suite green, and match the `.editorconfig` / tsconfig defaults already in the repo. Issues and discussions are open. + +## Terms of Service review + +A self-hosted, single-user, personal-use setup was reviewed against each provider's ToS (April 2026). Summary: + +| Provider | Verdict | Notes | +|---|---|---| +| Google Gemini | ✅ Likely OK | No adverse clause; proxy for personal use not prohibited. | +| Groq | ✅ Likely OK | Explicitly permits integrating into a "Customer Application." | +| Cerebras | ✅ Likely OK | Permitted; don't resell keys. | +| Mistral | ✅ Likely OK | APIs allowed for personal/internal business use. | +| OpenRouter | ✅ Likely OK | Private-use only; don't expose the proxy publicly. | +| Hugging Face | ✅ Likely OK | BYO-key proxying is the documented pattern. | +| Zhipu | ✅ Likely OK | Explicit "personal, non-commercial research" carve-out. | +| Moonshot / Kimi | ✅ Likely OK | Competitive-products clause is broad but not aimed at single-user proxies. | +| SambaNova | ⚠️ Ambiguous | Public terms are silent on APIs. | +| MiniMax | ⚠️ Ambiguous | Public terms silent. | +| Cloudflare Workers AI | ⚠️ Ambiguous | No adverse clause found. | +| NVIDIA NIM | ⚠️ Caution | Free tier is "evaluation only, not production." | +| GitHub Models | ⚠️ Caution | Free tier scoped to "experimentation." | +| Cohere | ❌ Avoid | Trial ToS §14 explicitly forbids personal/household use. | + +Rules of thumb that keep most providers happy: **one account per provider**, **no reselling**, **no sharing your endpoint with other humans**, **don't hammer a free tier as a paid production backend**. This is informational, not legal advice — read each provider's ToS and make your own call. + +## Disclaimer + +**This project is for personal experimentation and learning, not production.** Free tiers exist so developers can prototype against them; they aren't a stable, supported inference substrate and shouldn't be treated as one. If you build something real on top of FreeLLMAPI, swap in a paid API before you ship. Your relationship with each upstream provider is governed by the terms you accepted when you created your account — those terms still apply when the traffic is proxied through this project, and you're responsible for complying with them. + +## License + +[MIT](./LICENSE) diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/client/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/client/components.json b/client/components.json new file mode 100644 index 0000000..15addee --- /dev/null +++ b/client/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c5428bf --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + FreeLLMAPI · Unified LLM Router + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..abd4c4f --- /dev/null +++ b/client/package.json @@ -0,0 +1,47 @@ +{ + "name": "@freellmapi/client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.7", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.97.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.8.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1", + "shadcn": "^4.2.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } +} diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/icons.svg b/client/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/client/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..1b31f6c --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import KeysPage from '@/pages/KeysPage' +import PlaygroundPage from '@/pages/PlaygroundPage' +import FallbackPage from '@/pages/FallbackPage' +import AnalyticsPage from '@/pages/AnalyticsPage' + +const queryClient = new QueryClient() + +function NavItem({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + `relative text-sm px-1 py-4 transition-colors ${ + isActive + ? 'text-foreground after:absolute after:inset-x-0 after:-bottom-px after:h-px after:bg-foreground' + : 'text-muted-foreground hover:text-foreground' + }` + } + > + {children} + + ) +} + +function DarkModeToggle() { + const [dark, setDark] = useState(() => + typeof window !== 'undefined' && document.documentElement.classList.contains('dark') + ) + + useEffect(() => { + const stored = localStorage.getItem('theme') + if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark') + setDark(true) + } + }, []) + + function toggle() { + const next = !dark + setDark(next) + document.documentElement.classList.toggle('dark', next) + localStorage.setItem('theme', next ? 'dark' : 'light') + } + + return ( + + ) +} + +function Brand() { + return ( +
+ + FreeLLMAPI +
+ ) +} + +function App() { + return ( + + +
+
+
+ + +
+ +
+
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+ ) +} + +export default App diff --git a/client/src/assets/vite.svg b/client/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/client/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/client/src/components/page-header.tsx b/client/src/components/page-header.tsx new file mode 100644 index 0000000..b8131c7 --- /dev/null +++ b/client/src/components/page-header.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +export function PageHeader({ + title, + description, + actions, +}: { + title: string + description?: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx new file mode 100644 index 0000000..09df753 --- /dev/null +++ b/client/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/client/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/client/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/client/src/components/ui/label.tsx b/client/src/components/ui/label.tsx new file mode 100644 index 0000000..f162996 --- /dev/null +++ b/client/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +