diff --git a/README.md b/README.md index 20f7c51..934d62e 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,85 @@

English

-# ModelHub -
- -![ModelHub](https://img.shields.io/badge/ModelHub-AI%20Gateway-blue?style=for-the-badge) - -**Gateway unificado para múltiplos provedores de IA com API compatível com OpenAI** - -[![CI](https://github.com/actus7/modelhub/actions/workflows/ci.yml/badge.svg)](https://github.com/actus7/modelhub/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Node.js](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org) -[![Next.js](https://img.shields.io/badge/Next.js-16.2-black)](https://nextjs.org/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/) - -[![Deploy with Vercel](https://vercel.com/button)](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 + +

ModelHub

+ +

Gateway unificado para múltiplos provedores de IA com API compatível com OpenAI.

+ +

+ CI + Licença MIT + Node.js >= 22 + Next.js 16.2 + TypeScript 5 +

+ +

+ Visão geral · + Funcionalidades · + Quickstart · + API · + Arquitetura · + Deploy +

+ +

+ Deploy with Vercel +

--- -## 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`) + }, + ) +})