diff --git a/README.md b/README.md
index 20f7c51..934d62e 100644
--- a/README.md
+++ b/README.md
@@ -1,77 +1,85 @@
English
-# ModelHub
-
-
-
-
-**Gateway unificado para múltiplos provedores de IA com API compatível com OpenAI**
-
-[](https://github.com/actus7/modelhub/actions/workflows/ci.yml)
-[](LICENSE)
-[](https://nodejs.org)
-[](https://nextjs.org/)
-[](https://www.typescriptlang.org/)
-
-[](https://vercel.com/new/clone?repository-url=https://github.com/actus7/modelhub)
-
-[Sobre](#sobre) • [Funcionalidades](#funcionalidades) • [Setup](#setup-local) • [API](#api) • [Arquitetura](#arquitetura) • [CI/CD](#cicd)
-
+

+
+
ModelHub
+
+
Gateway unificado para múltiplos provedores de IA com API compatível com OpenAI.
+
+
+
+
+
+
+
+
+
+
+ Visão geral ·
+ Funcionalidades ·
+ Quickstart ·
+ API ·
+ Arquitetura ·
+ Deploy
+
+
+
+
+
---
-## Sobre
+## Visão geral
-ModelHub é uma plataforma open-source para centralizar o acesso a vários provedores de IA em uma única interface. Ele expõe uma API compatível com OpenAI, uma interface de chat, gerenciamento de credenciais por usuário e dashboard de uso.
+ModelHub centraliza OpenAI, Google, Groq, Mistral, OpenRouter e outros provedores em uma única plataforma open-source. Ele entrega uma API compatível com OpenAI, chat web autenticado, gerenciamento seguro de credenciais e dashboard de uso.
-A ideia é simples: em vez de cada aplicação integrar separadamente OpenAI, Google, Groq, Mistral, OpenRouter e outros, o ModelHub fica no meio e padroniza autenticação, roteamento, logs, custos e fallbacks.
+Em vez de cada aplicação integrar vários provedores separadamente, o ModelHub padroniza autenticação, roteamento, logs, custos, catálogo de modelos e fallbacks.
## Funcionalidades
-- **API Gateway OpenAI-compatible**: endpoints `/v1/chat/completions` e `/v1/models` para uso programático.
-- **Chat web autenticado**: interface pronta para conversar com modelos configurados.
-- **Gerenciamento de API keys**: crie chaves ModelHub para clientes, scripts e integrações.
-- **Credenciais por provider**: salve chaves de provedores com criptografia no banco.
-- **Dashboard de uso**: acompanhe requests, custos estimados, status codes, tokens e logs recentes.
-- **Roteamento inteligente**: tiers por complexidade, overrides por tarefa e fallbacks quando modelos falham.
-- **Suporte a anexos**: imagens, PDFs e documentos no fluxo de chat.
-- **Catálogo dinâmico de modelos**: lista modelos locais e busca modelos remotos quando o provider suporta.
-- **Rate limit e cooldown**: proteção básica contra abuso e provedores instáveis.
-- **Deploy Vercel-ready**: build e preview integrados ao fluxo de PR.
+
+
+ API OpenAI-compatible Use /v1/chat/completions e /v1/models com clientes existentes. |
+ Chat web Interface autenticada para conversar com modelos configurados. |
+
+
+ Credenciais seguras API keys ModelHub e chaves de provedores criptografadas por usuário. |
+ Dashboard de uso Requests, custos estimados, status codes, tokens e logs recentes. |
+
+
+ Roteamento inteligente Tiers por complexidade, overrides por tarefa e fallbacks automáticos. |
+ Anexos no chat Suporte a imagens, PDFs e documentos. |
+
+
+ Catálogo dinâmico Modelos locais e busca remota quando o provider suporta. |
+ Pronto para produção Rate limit, cooldown, headers de segurança, CI e deploy na Vercel. |
+
+
## Provedores
O catálogo fica em `server/lib/catalog.ts` e cada adapter vive em `server/providers/`.
-Provedores suportados incluem:
-
-- OpenAI
-- Google AI Studio
-- Groq
-- Mistral, incluindo Codestral como modelo Mistral
-- OpenRouter
-- HuggingFace
-- DeepSeek
-- Perplexity
-- Together AI
-- Fireworks AI
-- Cohere
-- Cloudflare Workers AI
-- Ollama e Ollama Cloud
-- GitHub Models
-- GitHub Copilot
-- Qwen e Qwen Token Plan
-- Z.ai e Z.ai Coding Plan
-- Moonshot/Kimi
-- NVIDIA NIM
-- Pollinations
-- Puter
+| Suportados | |
+|---|---|
+| OpenAI | Google AI Studio |
+| Groq | Mistral / Codestral |
+| OpenRouter | HuggingFace |
+| DeepSeek | Perplexity |
+| Together AI | Fireworks AI |
+| Cohere | Cloudflare Workers AI |
+| Ollama / Ollama Cloud | GitHub Models |
+| GitHub Copilot | Qwen / Qwen Token Plan |
+| Z.ai / Z.ai Coding Plan | Moonshot / Kimi |
+| NVIDIA NIM | Pollinations / Puter |
Providers quebrados ou duplicados devem ser removidos do catálogo e do registry para não aparecerem na tela de integrações.
-## Requisitos
+## Quickstart
+
+### Requisitos
- Node.js >= 22
- pnpm >= 10
@@ -80,7 +88,7 @@ Providers quebrados ou duplicados devem ser removidos do catálogo e do registry
- `ENCRYPTION_KEY` de 64 caracteres hexadecimais
- Chaves dos provedores que você pretende usar
-## Setup local
+### Instalação local
```bash
git clone https://github.com/actus7/modelhub.git
@@ -185,11 +193,13 @@ O campo `model` segue o formato `provider/model`, por exemplo:
## Interface web
-- `/chat`: conversa com provedores configurados.
-- `/setup`: tela de integrações e credenciais por provider.
-- `/dashboard`: API keys, uso, custos, logs e routing.
-- `/account`: informações da conta.
-- `/playground`: comparação/teste de providers.
+| Rota | Descrição |
+|---|---|
+| `/chat` | Conversa com provedores configurados |
+| `/setup` | Integrações e credenciais por provider |
+| `/dashboard` | API keys, uso, custos, logs e routing |
+| `/account` | Informações da conta |
+| `/playground` | Comparação e teste de providers |
Rotas autenticadas são protegidas por `proxy.ts`.
@@ -226,17 +236,7 @@ A aplicação usa duas camadas:
O banco é PostgreSQL via Neon, acessado com Prisma 7 e `@prisma/adapter-neon`.
-Modelos importantes:
-
-- `User`
-- `ApiKey`
-- `ProviderCredential`
-- `Conversation`
-- `Message`
-- `ConversationAttachment`
-- `UsageLog`
-- `UserMemory`
-- `UserSettings`
+Modelos importantes: `User`, `ApiKey`, `ProviderCredential`, `Conversation`, `Message`, `ConversationAttachment`, `UsageLog`, `UserMemory` e `UserSettings`.
Para mudanças de schema:
@@ -293,8 +293,6 @@ docker run --env-file .env -p 3000:3000 modelhub
Veja `CONTRIBUTING.md`.
-Fluxo recomendado:
-
```bash
git checkout -b fix/minha-mudanca
pnpm test
@@ -322,10 +320,4 @@ MIT. Veja `LICENSE`.
## Agradecimentos
-- Next.js
-- Hono
-- Prisma
-- Neon
-- shadcn/ui
-- Vitest
-- Comunidade open-source
+Next.js · Hono · Prisma · Neon · shadcn/ui · Vitest · Comunidade open-source
diff --git a/components/chat/chat-page.tsx b/components/chat/chat-page.tsx
index a343c71..fd28134 100644
--- a/components/chat/chat-page.tsx
+++ b/components/chat/chat-page.tsx
@@ -5,6 +5,7 @@ import Image from "next/image";
import { useSearchParams } from "next/navigation";
import {
type CloudDeploymentSummary,
+ type ProviderModel,
type UiProvider,
} from "@/lib/contracts";
import {
@@ -137,6 +138,13 @@ import {
type PersistedConversationMessage,
} from "@/lib/chat-utils";
+const AUTO_PROVIDER_ID = "modelhub-auto";
+const AUTO_MODEL: ProviderModel = {
+ capabilities: { documents: true, images: true, reasoning: true },
+ id: "auto",
+ name: "Auto · Smart Routing",
+};
+
export function ChatPage() {
const { credentials, providers, refreshCredentials } = useAppState();
const [selectedProviderId, setSelectedProviderId] = useState("");
@@ -218,6 +226,22 @@ export function ChatPage() {
}
}, [searchParams, openclawDeployments]);
+ const autoProvider = useMemo(() => ({
+ base: "/v1",
+ category: "gateway",
+ hasModels: true,
+ id: AUTO_PROVIDER_ID,
+ label: "Auto · Smart Routing",
+ localModels: [AUTO_MODEL],
+ runtime: {
+ authMode: "none",
+ externalApi: false,
+ kind: "server",
+ openAiCompatible: true,
+ transport: "openai-compatible",
+ },
+ }), []);
+
const openclawProviders = useMemo(
() => {
const providerLabels = new Map(providers.map((provider) => [provider.id, provider.label]));
@@ -253,9 +277,9 @@ export function ChatPage() {
const selectedProvider = useMemo(
() =>
- [...providers, ...openclawProviders].find((provider) => provider.id === selectedProviderId) ??
+ [autoProvider, ...providers, ...openclawProviders].find((provider) => provider.id === selectedProviderId) ??
null,
- [providers, openclawProviders, selectedProviderId],
+ [autoProvider, providers, openclawProviders, selectedProviderId],
);
const browserProviderAdapter = useMemo(
() => getBrowserChatProviderAdapter(selectedProviderId),
@@ -345,7 +369,7 @@ export function ChatPage() {
const preferred =
(globalThis.window?.localStorage.getItem("selected-provider") ?? null) ??
- (providers.find((provider) => provider.id === "gateway")?.id ?? providers[0]?.id ?? "");
+ AUTO_PROVIDER_ID;
setSelectedProviderId(preferred);
}, [providers, selectedProviderId]);
@@ -824,14 +848,21 @@ export function ChatPage() {
setBrowserProviderAuthState("signed-in");
} else {
let parsedStream: Awaited> | null = null;
- const response = await apiFetch(`${selectedProvider.base}/api/chat`, {
- body: JSON.stringify(requestPayload),
- headers: {
- "Content-Type": "application/json",
+ const response = await apiFetch(
+ selectedProviderId === AUTO_PROVIDER_ID ? "/v1/chat/completions" : `${selectedProvider.base}/api/chat`,
+ {
+ body: JSON.stringify(
+ selectedProviderId === AUTO_PROVIDER_ID
+ ? { messages: nextConversation, model: selectedModelId }
+ : requestPayload,
+ ),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ signal: controller.signal,
},
- method: "POST",
- signal: controller.signal,
- });
+ );
if (!response.ok) {
const errorMessage = await parseApiErrorResponse(response);
@@ -981,19 +1012,27 @@ export function ChatPage() {
const titleConvId = convId;
void (async () => {
try {
- const titleResponse = await apiFetch(`${selectedProvider.base}/api/chat`, {
- body: JSON.stringify({
- messages: [
- {
- role: "user",
- parts: [{ type: "text", text: buildTitleGenerationPrompt(text, fullText) }],
- },
- ],
- modelId: selectedProvider.hasModels ? selectedModelId : undefined,
- }),
- headers: { "Content-Type": "application/json" },
- method: "POST",
- });
+ const titleMessages = [
+ {
+ role: "user",
+ parts: [{ type: "text", text: buildTitleGenerationPrompt(text, fullText) }],
+ },
+ ];
+ const titleResponse = await apiFetch(
+ selectedProviderId === AUTO_PROVIDER_ID ? "/v1/chat/completions" : `${selectedProvider.base}/api/chat`,
+ {
+ body: JSON.stringify(
+ selectedProviderId === AUTO_PROVIDER_ID
+ ? { messages: titleMessages, model: selectedModelId }
+ : {
+ messages: titleMessages,
+ modelId: selectedProvider.hasModels ? selectedModelId : undefined,
+ },
+ ),
+ headers: { "Content-Type": "application/json" },
+ method: "POST",
+ },
+ );
if (titleResponse.ok) {
const titleResult = await parseChatStream(titleResponse, {});
const cleanTitle = titleResult.text.trim().replaceAll(/^["']|["']$/g, "").slice(0, 100);
@@ -1181,6 +1220,16 @@ export function ChatPage() {
+
+ Roteamento
+
+
+ Auto · Smart Routing
+
+
+
+
+
{openclawProviders.length > 0 && (
Ambientes OpenClaw
diff --git a/server/routes/v1.ts b/server/routes/v1.ts
index 7b27ab3..78a0fda 100644
--- a/server/routes/v1.ts
+++ b/server/routes/v1.ts
@@ -1,9 +1,10 @@
import { Hono } from 'hono'
+import type { Context } from 'hono'
import { isProviderEnabled } from '../lib/catalog'
import { jsonErrorResponse, vercelStreamToOpenAiSse } from '../lib/provider-core'
import { withProviderMetadata } from '../lib/observability'
-import { getActiveApiKey, protectedCors, securityHeaders } from '../lib/security'
+import { authenticateAccess, getActiveApiKey, protectedCors, securityHeaders } from '../lib/security'
import { providerRegistry, getProviderModels, isProviderAvailableViaExternalApi } from '../providers/registry'
import { resolveRouting, type RoutingResult, type RoutingCandidate } from '../lib/routing/routing-resolver'
import type { RoutingTier } from '../lib/routing/complexity-scorer'
@@ -26,17 +27,19 @@ function parseProviderAndModel(unifiedModelId: string): { providerId: string; mo
}
async function resolveAutoRouting(
- c: { req: { header: (name: string) => string | undefined } },
+ c: Context,
body: Record,
forcedTierOverride?: RoutingTier,
): Promise<{ routing: RoutingResult; providerId: string; modelId: string } | null> {
- // Extrair userId do token de autenticação
+ let userId = c.get('userId') as string | undefined
+
const authHeader = c.req.header('Authorization')
const token = authHeader?.replace(/^Bearer\s+/i, '').trim()
- if (!token) return null
-
- const apiKey = await getActiveApiKey(token)
- if (!apiKey) return null
+ if (!userId && token) {
+ const apiKey = await getActiveApiKey(token)
+ userId = apiKey?.userId
+ }
+ if (!userId) return null
const messages = Array.isArray(body.messages)
? (body.messages as Array<{ role: string; content: unknown }>)
@@ -53,7 +56,7 @@ async function resolveAutoRouting(
: undefined)
const result = await resolveRouting({
- userId: apiKey.userId,
+ userId,
messages,
forcedTier,
toolNames: tools,
@@ -302,6 +305,9 @@ app.post('/v1/chat/completions', async (c) => {
// com fallback automático para os demais modelos configurados quando o
// modelo escolhido falha (>=400, exceto erros de request do cliente).
if (rawModel === 'auto' || rawModel.endsWith(':auto')) {
+ const accessError = await authenticateAccess(c)
+ if (accessError) return accessError
+
const tierPrefix = rawModel.endsWith(':auto') ? rawModel.replace(':auto', '') : undefined
const forcedTier = VALID_TIERS.includes(tierPrefix as RoutingTier) ? (tierPrefix as RoutingTier) : undefined
const resolved = await resolveAutoRouting(c, body, forcedTier)
diff --git a/server/tests/v1-auto-routing.test.ts b/server/tests/v1-auto-routing.test.ts
new file mode 100644
index 0000000..4c5fd89
--- /dev/null
+++ b/server/tests/v1-auto-routing.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ getSession: vi.fn(),
+ providerHandler: vi.fn(),
+ resolveRouting: vi.fn(),
+}))
+
+vi.mock('../lib/catalog', () => ({
+ isProviderEnabled: vi.fn(() => true),
+}))
+
+vi.mock('../lib/db', () => ({
+ prisma: {
+ apiKey: { findFirst: vi.fn(), update: vi.fn().mockReturnValue({ catch: vi.fn() }) },
+ user: { upsert: vi.fn() },
+ },
+}))
+
+vi.mock('@/lib/auth/server', () => ({
+ auth: { getSession: mocks.getSession },
+}))
+
+vi.mock('../lib/routing/routing-resolver', () => ({
+ resolveRouting: mocks.resolveRouting,
+}))
+
+vi.mock('../lib/routing/fallback', () => ({
+ isInCooldown: vi.fn(() => false),
+ recordCooldown: vi.fn(),
+ shouldTriggerFallback: vi.fn(() => false),
+}))
+
+vi.mock('../providers/registry', () => ({
+ getProviderModels: vi.fn(),
+ isProviderAvailableViaExternalApi: vi.fn(() => true),
+ providerRegistry: {
+ demo: {
+ handler: mocks.providerHandler,
+ models: [],
+ },
+ },
+}))
+
+const v1Fetch = (await import('../routes/v1')).default
+
+describe('POST /v1/chat/completions auto routing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getSession.mockResolvedValue({
+ data: {
+ session: { id: 'session-1' },
+ user: { email: 'user@example.com', id: 'user-1', name: 'User' },
+ },
+ })
+ mocks.resolveRouting.mockResolvedValue({
+ confidence: 0.92,
+ fallbacks: [],
+ modelId: 'demo-model',
+ providerId: 'demo',
+ reason: 'complexity',
+ taskCategory: null,
+ tier: 'standard',
+ })
+ mocks.providerHandler.mockResolvedValue(new Response('0:" routed"\nd:{"finishReason":"stop"}\n', {
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
+ status: 200,
+ }))
+ })
+
+ it('authenticates the web session and dispatches the model selected by routing', async () => {
+ const response = await v1Fetch(new Request('https://modelhub.test/v1/chat/completions', {
+ body: JSON.stringify({
+ messages: [{ content: 'hello', role: 'user' }],
+ model: 'auto',
+ }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ }))
+
+ expect(response.status).toBe(200)
+ expect(mocks.resolveRouting).toHaveBeenCalledWith(expect.objectContaining({
+ messages: [{ content: 'hello', role: 'user' }],
+ userId: 'user-1',
+ }))
+
+ const forwardedRequest = mocks.providerHandler.mock.calls[0]?.[0] as Request
+ expect(new URL(forwardedRequest.url).pathname).toBe('/demo/api/chat')
+ expect(await forwardedRequest.json()).toMatchObject({ modelId: 'demo-model' })
+
+ expect(response.headers.get('X-ModelHub-Tier')).toBe('standard')
+ expect(response.headers.get('X-ModelHub-Provider')).toBe('demo')
+ expect(response.headers.get('X-ModelHub-Model')).toBe('demo-model')
+ expect(await response.text()).toContain('routed')
+ })
+
+ it.each(['simple', 'standard', 'complex', 'reasoning'] as const)(
+ 'routes the forced %s lane through /v1/chat/completions',
+ async (tier) => {
+ mocks.resolveRouting.mockResolvedValueOnce({
+ confidence: 1,
+ fallbacks: [],
+ modelId: `${tier}-model`,
+ providerId: 'demo',
+ reason: 'configured',
+ taskCategory: null,
+ tier,
+ })
+
+ const response = await v1Fetch(new Request('https://modelhub.test/v1/chat/completions', {
+ body: JSON.stringify({
+ messages: [{ content: `test ${tier}`, role: 'user' }],
+ model: `${tier}:auto`,
+ }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ }))
+
+ expect(response.status).toBe(200)
+ expect(mocks.resolveRouting).toHaveBeenLastCalledWith(expect.objectContaining({ forcedTier: tier }))
+
+ const forwardedRequest = mocks.providerHandler.mock.calls.at(-1)?.[0] as Request
+ expect(await forwardedRequest.json()).toMatchObject({ modelId: `${tier}-model` })
+ expect(response.headers.get('X-ModelHub-Tier')).toBe(tier)
+ expect(response.headers.get('X-ModelHub-Model')).toBe(`${tier}-model`)
+ },
+ )
+})