From 2cc177385415a2694148d9956023bb4543ecb5ea Mon Sep 17 00:00:00 2001 From: Arvind Arikatla Date: Sun, 16 Aug 2026 19:15:37 -0700 Subject: [PATCH 1/2] feat: dynamic multi-provider AI engine with custom endpoints (BYOM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenAI as a first-class provider and support user-registered OpenAI-compatible endpoints (Ollama, LM Studio, vLLM, OpenRouter, Groq). Model catalogs are now discovered live from each provider's model listing API instead of hardcoded lists. Server: - New OpenAICompatibleProvider core (fetch-based, no SDK) shared by the branded OpenAI provider and custom endpoints - Adaptive structured output: json_schema → json_object → prompt-based JSON with per-endpoint/model mode caching; tool calls degrade to plain chat on engines that reject tools - Live model discovery for Gemini/Claude/OpenAI/custom with guardrails: 1-year recency window, non-chat modality stripping (embeddings, audio, TTS, image/video, moderation), and alias/snapshot dedupe - SQLite-backed catalog cache (model_catalog) + custom_endpoints table - /api/endpoints CRUD with pre-flight connectivity checks - /api/config/refresh-models for 1-click catalog re-sync - Keys masked as ••••1234 in all responses; masked values never re-saved; key removal drops the catalog and cached provider instances - Key verification now lists models (no token spend) and triggers immediate discovery on save Frontend: - Settings: OpenAI key card, connection-error badges, masked key hints, custom endpoint manager with quick-fill presets, Refresh Models button, family labels in the grouped model picker - Sidebar: grouped picker includes custom endpoints; empty state links to Settings when nothing is configured - Store: active model safely falls back to the first available model when its provider/endpoint disappears Tests: 59 new tests covering recency/modality filtering, alias dedupe, key masking, adaptive JSON fallbacks, endpoint connectivity, namespaced custom model routing, and the settings/sidebar UX flows. Co-Authored-By: Claude Fable 5 --- README.md | 3 + docker-compose.yml | 1 + server/__tests__/model_filter.test.js | 191 ++++++++ server/__tests__/openai_compatible.test.js | 262 +++++++++++ server/db.js | 17 + server/index.js | 2 + server/providers/base.js | 11 + server/providers/catalog.js | 124 ++++++ server/providers/claude.js | 66 ++- server/providers/custom.js | 65 +++ server/providers/gemini.js | 64 ++- server/providers/index.js | 262 +++++++++-- server/providers/model_filter.js | 183 ++++++++ server/providers/openai.js | 55 +++ server/providers/openai_compatible.js | 407 ++++++++++++++++++ server/routes/config.js | 67 ++- server/routes/endpoints.js | 208 +++++++++ server/utils/mask.js | 34 ++ ...hallenger_empirical_stress_phase2.test.jsx | 9 + src/__tests__/chat_integration.test.jsx | 9 + src/__tests__/guide_settings.test.jsx | 9 + src/__tests__/model_management.test.jsx | 318 ++++++++++++++ src/components/layout/Sidebar.jsx | 74 ++-- src/index.css | 4 + src/pages/SettingsPage.jsx | 389 ++++++++++++++++- src/stores/appStore.js | 25 +- src/utils/api.js | 20 +- 27 files changed, 2768 insertions(+), 111 deletions(-) create mode 100644 server/__tests__/model_filter.test.js create mode 100644 server/__tests__/openai_compatible.test.js create mode 100644 server/providers/catalog.js create mode 100644 server/providers/custom.js create mode 100644 server/providers/model_filter.js create mode 100644 server/providers/openai.js create mode 100644 server/providers/openai_compatible.js create mode 100644 server/routes/endpoints.js create mode 100644 server/utils/mask.js create mode 100644 src/__tests__/model_management.test.jsx diff --git a/README.md b/README.md index 774c265..143573e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ npm run dev # Vite (5173) + Express (3100) | `DB_PATH` | `./data/toolbox.db` | SQLite database path | | `GEMINI_API_KEY` | — | Gemini API key (can also be set via Settings UI) | | `CLAUDE_API_KEY` | — | Claude API key (can also be set via Settings UI) | +| `OPENAI_API_KEY` | — | OpenAI API key (can also be set via Settings UI) | + +Custom OpenAI-compatible endpoints (Ollama, LM Studio, vLLM, OpenRouter, Groq, ...) are managed in the Settings UI. The app discovers each provider's model catalog live, so new model releases appear without an app update. All user data lives in a single SQLite file — back it up by copying that file. diff --git a/docker-compose.yml b/docker-compose.yml index e0b4125..d6eb8ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - DB_PATH=/app/data/toolbox.db - GEMINI_API_KEY=${GEMINI_API_KEY} - CLAUDE_API_KEY=${CLAUDE_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} deploy: resources: limits: diff --git a/server/__tests__/model_filter.test.js b/server/__tests__/model_filter.test.js new file mode 100644 index 0000000..f4ba602 --- /dev/null +++ b/server/__tests__/model_filter.test.js @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest' +import { + isChatModel, + isWithinRecencyWindow, + dedupeAliases, + baseAliasOf, + applyCatalogGuardrails, + inferModelFamily, + prettyModelName, + RECENCY_WINDOW_MS, +} from '../providers/model_filter.js' +import { maskSecret, isMaskedValue } from '../utils/mask.js' + +const NOW = Date.parse('2026-08-16T00:00:00Z') +const DAY = 24 * 60 * 60 * 1000 + +describe('modality guardrails (isChatModel)', () => { + it('keeps chat and reasoning models', () => { + const chatModels = [ + 'gpt-5.1', + 'gpt-5.1-mini', + 'o4-mini', + 'chatgpt-4o-latest', + 'claude-sonnet-4-6', + 'claude-opus-4-8', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'llama3.2:3b', + 'qwen2.5-coder', + ] + for (const id of chatModels) { + expect(isChatModel(id), id).toBe(true) + } + }) + + it('drops embedding models', () => { + expect(isChatModel('text-embedding-3-large')).toBe(false) + expect(isChatModel('gemini-embedding-001')).toBe(false) + expect(isChatModel('nomic-embed-text')).toBe(false) + }) + + it('drops audio, transcription, and speech models', () => { + expect(isChatModel('whisper-1')).toBe(false) + expect(isChatModel('gpt-4o-transcribe')).toBe(false) + expect(isChatModel('gpt-4o-mini-tts')).toBe(false) + expect(isChatModel('gpt-4o-audio-preview')).toBe(false) + expect(isChatModel('gpt-4o-realtime-preview')).toBe(false) + expect(isChatModel('gemini-2.5-flash-preview-tts')).toBe(false) + }) + + it('drops image and video generation models', () => { + expect(isChatModel('dall-e-3')).toBe(false) + expect(isChatModel('gpt-image-1')).toBe(false) + expect(isChatModel('imagen-4.0-generate-001')).toBe(false) + expect(isChatModel('veo-3.1-generate-preview')).toBe(false) + }) + + it('drops moderation and legacy completion models', () => { + expect(isChatModel('omni-moderation-latest')).toBe(false) + expect(isChatModel('text-moderation-007')).toBe(false) + expect(isChatModel('babbage-002')).toBe(false) + expect(isChatModel('davinci-002')).toBe(false) + expect(isChatModel('gpt-3.5-turbo-instruct')).toBe(false) + }) + + it('handles empty input', () => { + expect(isChatModel('')).toBe(false) + expect(isChatModel(undefined)).toBe(false) + }) +}) + +describe('recency guardrail (isWithinRecencyWindow)', () => { + it('keeps models released within the last year', () => { + expect(isWithinRecencyWindow(NOW - 30 * DAY, NOW)).toBe(true) + expect(isWithinRecencyWindow(NOW - 364 * DAY, NOW)).toBe(true) + }) + + it('drops models released over a year ago', () => { + expect(isWithinRecencyWindow(NOW - 366 * DAY, NOW)).toBe(false) + expect(isWithinRecencyWindow(NOW - 3 * 365 * DAY, NOW)).toBe(false) + }) + + it('keeps the exact boundary', () => { + expect(isWithinRecencyWindow(NOW - RECENCY_WINDOW_MS, NOW)).toBe(true) + }) + + it('keeps models without a release timestamp', () => { + expect(isWithinRecencyWindow(null, NOW)).toBe(true) + expect(isWithinRecencyWindow(undefined, NOW)).toBe(true) + }) +}) + +describe('alias dedupe (dedupeAliases / baseAliasOf)', () => { + it('strips snapshot suffixes', () => { + expect(baseAliasOf('gpt-4o-2024-08-06')).toBe('gpt-4o') + expect(baseAliasOf('claude-3-5-haiku-20241022')).toBe('claude-3-5-haiku') + expect(baseAliasOf('gpt-4-0613')).toBe('gpt-4') + expect(baseAliasOf('gpt-5.1')).toBe('gpt-5.1') + }) + + it('drops pinned snapshots when the floating alias exists', () => { + const result = dedupeAliases([ + { id: 'gpt-4o' }, + { id: 'gpt-4o-2024-08-06' }, + { id: 'gpt-4o-2024-11-20' }, + { id: 'o4-mini' }, + ]) + expect(result.map((m) => m.id)).toEqual(['gpt-4o', 'o4-mini']) + }) + + it('keeps a snapshot when no floating alias exists', () => { + const result = dedupeAliases([{ id: 'claude-sonnet-4-6-20251001' }]) + expect(result.map((m) => m.id)).toEqual(['claude-sonnet-4-6-20251001']) + }) + + it('collapses exact duplicate IDs', () => { + const result = dedupeAliases([{ id: 'gpt-5.1' }, { id: 'gpt-5.1' }]) + expect(result).toHaveLength(1) + }) +}) + +describe('applyCatalogGuardrails (all passes combined)', () => { + it('filters modality, recency, and aliases in one pass', () => { + const raw = [ + { id: 'gpt-5.1', releasedAt: NOW - 10 * DAY }, + { id: 'gpt-5.1-2026-08-01', releasedAt: NOW - 15 * DAY }, + { id: 'gpt-4-0613', releasedAt: NOW - 3 * 365 * DAY }, + { id: 'text-embedding-3-large', releasedAt: NOW - 5 * DAY }, + { id: 'whisper-1', releasedAt: NOW - 5 * DAY }, + { id: 'gemini-3.5-flash', releasedAt: null }, + ] + const result = applyCatalogGuardrails(raw, NOW) + expect(result.map((m) => m.id)).toEqual(['gpt-5.1', 'gemini-3.5-flash']) + }) +}) + +describe('family inference', () => { + it('classifies Gemini families', () => { + expect(inferModelFamily('gemini', 'gemini-3.5-flash')).toBe('Flash') + expect(inferModelFamily('gemini', 'gemini-3.1-pro')).toBe('Pro') + expect(inferModelFamily('gemini', 'gemini-3.1-flash-lite')).toBe('Flash-Lite') + }) + + it('classifies Claude families', () => { + expect(inferModelFamily('claude', 'claude-sonnet-4-6')).toBe('Sonnet') + expect(inferModelFamily('claude', 'claude-haiku-4-5')).toBe('Haiku') + expect(inferModelFamily('claude', 'claude-opus-4-8')).toBe('Opus') + }) + + it('classifies OpenAI families', () => { + expect(inferModelFamily('openai', 'gpt-5.1')).toBe('Flagship') + expect(inferModelFamily('openai', 'gpt-5.1-mini')).toBe('Mini') + expect(inferModelFamily('openai', 'o4-mini')).toBe('Mini') + expect(inferModelFamily('openai', 'o3')).toBe('Reasoning') + }) + + it('returns null for unknown providers', () => { + expect(inferModelFamily('custom', 'llama3.2:3b')).toBe(null) + }) +}) + +describe('prettyModelName', () => { + it('formats raw model IDs for display', () => { + expect(prettyModelName('llama3.2:3b')).toBe('Llama3.2 3b') + expect(prettyModelName('gpt-4o')).toBe('GPT 4o') + expect(prettyModelName('models/gemini-3.5-flash')).toBe('Gemini 3.5 Flash') + }) +}) + +describe('credential masking (maskSecret)', () => { + it('masks keys as bullets plus the last 4 characters', () => { + expect(maskSecret('sk-ant-api03-abcdef1234')).toBe('••••1234') + expect(maskSecret('AIzaSyD-9876')).toBe('••••9876') + }) + + it('never reveals short secrets', () => { + expect(maskSecret('abcd')).toBe('••••') + expect(maskSecret('ab')).toBe('••••') + }) + + it('returns empty string for empty values', () => { + expect(maskSecret('')).toBe('') + expect(maskSecret(null)).toBe('') + expect(maskSecret(undefined)).toBe('') + }) + + it('detects masked values so they are never re-saved', () => { + expect(isMaskedValue('••••1234')).toBe(true) + expect(isMaskedValue('sk-real-key')).toBe(false) + }) +}) diff --git a/server/__tests__/openai_compatible.test.js b/server/__tests__/openai_compatible.test.js new file mode 100644 index 0000000..c57337c --- /dev/null +++ b/server/__tests__/openai_compatible.test.js @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { OpenAICompatibleProvider, _resetJsonModeCache } from '../providers/openai_compatible.js' +import { CustomEndpointProvider, customModelId, parseCustomModelId } from '../providers/custom.js' + +const BASE = 'http://localhost:11434/v1' + +/** Build a fetch Response-like object with a JSON body. */ +const jsonResponse = (body, ok = true, status = 200) => ({ + ok, + status, + json: async () => body, +}) + +/** Build a fetch Response-like object with an SSE stream body. */ +function sseResponse(lines) { + const encoder = new TextEncoder() + const chunks = lines.map((l) => encoder.encode(l)) + let i = 0 + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => + i < chunks.length ? { done: false, value: chunks[i++] } : { done: true, value: undefined }, + }), + }, + } +} + +const chatResponse = (content, extra = {}) => + jsonResponse({ choices: [{ message: { content, ...extra } }] }) + +beforeEach(() => { + _resetJsonModeCache() + fetch.mockReset() +}) + +describe('custom endpoint connectivity (fetchModels)', () => { + it('lists models from an OpenAI-compatible /models endpoint', async () => { + fetch.mockResolvedValueOnce( + jsonResponse({ data: [{ id: 'llama3.2:3b', created: 1750000000 }, { id: 'qwen2.5' }] }) + ) + const models = await OpenAICompatibleProvider.fetchModels('', BASE) + expect(models).toEqual([ + { id: 'llama3.2:3b', releasedAt: 1750000000000 }, + { id: 'qwen2.5', releasedAt: null }, + ]) + expect(fetch).toHaveBeenCalledWith(`${BASE}/models`, { headers: {} }) + }) + + it('sends a Bearer header when an API key is set', async () => { + fetch.mockResolvedValueOnce(jsonResponse({ data: [] })) + await OpenAICompatibleProvider.fetchModels('sk-test', BASE) + expect(fetch).toHaveBeenCalledWith(`${BASE}/models`, { + headers: { Authorization: 'Bearer sk-test' }, + }) + }) + + it('reports unreachable servers with a friendly message', async () => { + fetch.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(OpenAICompatibleProvider.fetchModels('', BASE)).rejects.toThrow( + `Could not reach ${BASE}` + ) + }) + + it('surfaces the server error message on HTTP failures', async () => { + fetch.mockResolvedValueOnce(jsonResponse({ error: { message: 'Invalid API key' } }, false, 401)) + await expect(OpenAICompatibleProvider.fetchModels('bad', BASE)).rejects.toThrow('Invalid API key') + }) + + it('verifies keys through testApiKey without spending tokens', async () => { + fetch.mockResolvedValueOnce(jsonResponse({ data: [] })) + const provider = new OpenAICompatibleProvider('sk-test', BASE) + await expect(provider.testApiKey('sk-test')).resolves.toBe(true) + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe(`${BASE}/models`) + }) +}) + +describe('adaptive structured output (generateJSON)', () => { + const schema = { + type: 'object', + properties: { cards: { type: 'array', items: { type: 'string' } } }, + } + + it('uses strict json_schema mode when the server supports it', async () => { + fetch.mockResolvedValueOnce(chatResponse('{"cards":["a"]}')) + const provider = new OpenAICompatibleProvider('', BASE) + const result = await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) + expect(result).toEqual({ cards: ['a'] }) + + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body.response_format.type).toBe('json_schema') + expect(body.response_format.json_schema.schema).toEqual(schema) + }) + + it('falls back json_schema → json_object → prompt when the engine lacks support', async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ error: { message: 'response_format json_schema is not supported' } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: { message: 'response_format json_object is not supported' } }, false, 400)) + .mockResolvedValueOnce(chatResponse('```json\n{"cards":["a","b"]}\n```')) + + const provider = new OpenAICompatibleProvider('', BASE) + const result = await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) + expect(result).toEqual({ cards: ['a', 'b'] }) + expect(fetch).toHaveBeenCalledTimes(3) + + const bodies = fetch.mock.calls.map(([, init]) => JSON.parse(init.body)) + expect(bodies[0].response_format.type).toBe('json_schema') + expect(bodies[1].response_format.type).toBe('json_object') + expect(bodies[2].response_format).toBeUndefined() + }) + + it('remembers the working mode per endpoint+model (no repeated failures)', async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ error: { message: 'json_schema not supported' } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: { message: 'json_object not supported' } }, false, 400)) + .mockResolvedValueOnce(chatResponse('{"cards":[]}')) + + const provider = new OpenAICompatibleProvider('', BASE) + await provider.generateJSON('make cards', schema, { model: 'llama3.2:3b' }) + expect(fetch).toHaveBeenCalledTimes(3) + + // Second call starts directly in prompt mode + fetch.mockResolvedValueOnce(chatResponse('{"cards":["c"]}')) + const result = await provider.generateJSON('more cards', schema, { model: 'llama3.2:3b' }) + expect(result).toEqual({ cards: ['c'] }) + expect(fetch).toHaveBeenCalledTimes(4) + const lastBody = JSON.parse(fetch.mock.calls[3][1].body) + expect(lastBody.response_format).toBeUndefined() + }) + + it('falls back when the model returns unparseable JSON', async () => { + fetch + .mockResolvedValueOnce(chatResponse('Sure! Here are your cards: a, b, c')) + .mockResolvedValueOnce(chatResponse('{"cards":["a"]}')) + + const provider = new OpenAICompatibleProvider('', BASE) + const result = await provider.generateJSON('make cards', schema, { model: 'm' }) + expect(result).toEqual({ cards: ['a'] }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('wraps array-root schemas for json_schema mode and unwraps the result', async () => { + const arraySchema = { type: 'array', items: { type: 'string' } } + fetch.mockResolvedValueOnce(chatResponse('{"items":["q1","q2"]}')) + + const provider = new OpenAICompatibleProvider('', BASE) + const result = await provider.generateJSON('make questions', arraySchema, { model: 'm' }) + expect(result).toEqual(['q1', 'q2']) + + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body.response_format.json_schema.schema.type).toBe('object') + expect(body.response_format.json_schema.schema.properties.items).toEqual(arraySchema) + }) + + it('throws the last error when every mode fails', async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 1' } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 2' } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: { message: 'nope 3' } }, false, 500)) + + const provider = new OpenAICompatibleProvider('', BASE) + await expect(provider.generateJSON('x', schema, { model: 'm' })).rejects.toThrow('nope 3') + }) +}) + +describe('streaming chat', () => { + it('yields text chunks from the SSE stream', async () => { + fetch.mockResolvedValueOnce( + sseResponse([ + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":" world"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ) + const provider = new OpenAICompatibleProvider('', BASE) + const chunks = [] + for await (const chunk of provider.streamChat('be brief', [], 'hi', { model: 'm' })) { + chunks.push(chunk) + } + expect(chunks).toEqual([ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ]) + }) + + it('degrades to plain chat when the engine rejects tools', async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ error: { message: 'tools is not supported by this model' } }, false, 400)) + .mockResolvedValueOnce( + sseResponse(['data: {"choices":[{"delta":{"content":"plain answer"}}]}\n\n', 'data: [DONE]\n\n']) + ) + + const provider = new OpenAICompatibleProvider('', BASE) + const tools = [{ name: 'lookup', description: 'd', parameters: { type: 'object' } }] + const chunks = [] + for await (const chunk of provider.streamChatWithTools('sys', [], 'hi', tools, async () => ({}), { model: 'm' })) { + chunks.push(chunk) + } + expect(chunks).toEqual([{ type: 'text', text: 'plain answer' }]) + }) + + it('executes tool calls and loops until the model answers', async () => { + fetch + .mockResolvedValueOnce( + jsonResponse({ + choices: [{ + message: { + content: null, + tool_calls: [{ id: 'c1', function: { name: 'lookup', arguments: '{"q":"cap"}' } }], + }, + }], + }) + ) + .mockResolvedValueOnce(chatResponse('CAP theorem says...')) + + const executed = [] + const provider = new OpenAICompatibleProvider('', BASE) + const tools = [{ name: 'lookup', description: 'd', parameters: { type: 'object' } }] + const chunks = [] + for await (const chunk of provider.streamChatWithTools( + 'sys', [], 'hi', tools, + async (name, args) => { executed.push([name, args]); return { found: true } }, + { model: 'm' } + )) { + chunks.push(chunk) + } + + expect(executed).toEqual([['lookup', { q: 'cap' }]]) + expect(chunks).toEqual([ + { type: 'tool', name: 'lookup' }, + { type: 'text', text: 'CAP theorem says...' }, + ]) + // Second request carries the tool result back to the model + const secondBody = JSON.parse(fetch.mock.calls[1][1].body) + expect(secondBody.messages.at(-1)).toMatchObject({ role: 'tool', tool_call_id: 'c1' }) + }) +}) + +describe('custom endpoint model namespacing', () => { + it('builds and parses namespaced model IDs (colons in upstream IDs survive)', () => { + const id = customModelId('ep-1', 'llama3.2:3b') + expect(id).toBe('custom:ep-1:llama3.2:3b') + expect(parseCustomModelId(id)).toEqual({ endpointId: 'ep-1', upstreamModelId: 'llama3.2:3b' }) + expect(parseCustomModelId('gemini-3.5-flash')).toBe(null) + }) + + it('strips the namespace before sending requests upstream', async () => { + fetch.mockResolvedValueOnce(chatResponse('ok')) + const provider = new CustomEndpointProvider({ + id: 'ep-1', + name: 'Homelab', + base_url: BASE, + api_key: '', + }) + await provider.generateText('hi', { model: 'custom:ep-1:llama3.2:3b' }) + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body.model).toBe('llama3.2:3b') + }) +}) diff --git a/server/db.js b/server/db.js index ecedb8a..9aa94b2 100644 --- a/server/db.js +++ b/server/db.js @@ -114,6 +114,23 @@ function migrate() { created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); + + -- Discovered AI model catalogs (per provider or custom endpoint) + CREATE TABLE IF NOT EXISTS model_catalog ( + catalog_id TEXT PRIMARY KEY, + models TEXT NOT NULL DEFAULT '[]', + fetched_at TEXT DEFAULT (datetime('now')) + ); + + -- User-registered OpenAI-compatible endpoints (BYOM) + CREATE TABLE IF NOT EXISTS custom_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + api_key TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); `) logger.info('[db] Migrations complete') diff --git a/server/index.js b/server/index.js index 0826100..4bcab3e 100644 --- a/server/index.js +++ b/server/index.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url' import logger from './utils/logger.js' import configRoutes from './routes/config.js' +import endpointRoutes from './routes/endpoints.js' import deckRoutes from './routes/decks.js' import boardRoutes from './routes/boards.js' import chatRoutes from './routes/chat.js' @@ -32,6 +33,7 @@ app.use(express.json({ limit: '10mb' })) // API routes app.use('/api/config', configRoutes) +app.use('/api/endpoints', endpointRoutes) app.use('/api/decks', deckRoutes) app.use('/api/boards', boardRoutes) app.use('/api/chat', chatRoutes) diff --git a/server/providers/base.js b/server/providers/base.js index d351f01..5948991 100644 --- a/server/providers/base.js +++ b/server/providers/base.js @@ -129,6 +129,17 @@ export class AIProvider { return models.length > 0 ? models[0].id : '' } + /** + * Check if a model ID belongs to this provider's namespace. + * Used as a fallback when a model is not in the static or discovered + * catalogs (e.g. a brand-new model typed in manually). + * @param {string} modelId + * @returns {boolean} + */ + static ownsModelId(modelId) { + return typeof modelId === 'string' && modelId.startsWith(this.providerId) + } + /** * Capability flags for feature-gating. * Routes and UI components can check these to gracefully degrade diff --git a/server/providers/catalog.js b/server/providers/catalog.js new file mode 100644 index 0000000..620978f --- /dev/null +++ b/server/providers/catalog.js @@ -0,0 +1,124 @@ +/** + * @fileoverview Model catalog cache (SQLite-backed). + * + * Discovered model lists persist in the `model_catalog` table so the UI + * loads instantly without hitting provider APIs on every request. + * + * Catalog IDs: + * 'gemini' | 'claude' | 'openai' → one row per cloud provider + * 'custom:' → one row per custom endpoint + * + * Refresh orchestration lives in providers/index.js (it owns the + * provider classes and API keys). This module owns persistence and + * entry normalization only. + */ + +import db from '../db.js' +import { applyCatalogGuardrails, inferModelFamily, prettyModelName } from './model_filter.js' +import { customModelId } from './custom.js' + +/** + * Persist a discovered model list for a catalog ID. + * @param {string} catalogId + * @param {Array} models - Normalized catalog entries + */ +export function saveCatalog(catalogId, models) { + db.prepare(` + INSERT INTO model_catalog (catalog_id, models, fetched_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(catalog_id) DO UPDATE SET models = excluded.models, fetched_at = excluded.fetched_at + `).run(catalogId, JSON.stringify(models)) +} + +/** + * Load a cached model list. + * @param {string} catalogId + * @returns {{ models: Array, fetchedAt: string }|null} + */ +export function loadCatalog(catalogId) { + const row = db.prepare('SELECT models, fetched_at FROM model_catalog WHERE catalog_id = ?').get(catalogId) + if (!row) return null + try { + return { models: JSON.parse(row.models), fetchedAt: row.fetched_at } + } catch { + return null + } +} + +/** + * Delete a cached model list (e.g. after key removal or endpoint deletion). + * @param {string} catalogId + */ +export function clearCatalog(catalogId) { + db.prepare('DELETE FROM model_catalog WHERE catalog_id = ?').run(catalogId) +} + +/** + * Normalize raw discovered models into catalog entries for a cloud provider. + * Applies the modality/recency/alias guardrails first. + * + * @param {string} providerId - 'gemini' | 'claude' | 'openai' + * @param {Array<{id, name?, description?, releasedAt?}>} rawModels + * @param {number} [nowMs] + * @returns {Array<{id, name, description, family, releasedAt}>} + */ +export function buildProviderCatalogEntries(providerId, rawModels, nowMs = Date.now()) { + const filtered = applyCatalogGuardrails(rawModels, nowMs) + const entries = filtered.map((m) => { + const family = inferModelFamily(providerId, m.id) + return { + id: m.id, + name: m.name || prettyModelName(m.id), + description: m.description || (family ? `${family} family` : ''), + family, + releasedAt: m.releasedAt ?? null, + } + }) + // Newest first when timestamps exist; stable order otherwise + entries.sort((a, b) => (b.releasedAt || 0) - (a.releasedAt || 0)) + return entries +} + +/** + * Normalize raw discovered models into catalog entries for a custom endpoint. + * Model IDs get the `custom::` namespace so the global picker + * and provider resolution can route them back to this endpoint. + * + * @param {{ id: string, name: string }} endpoint - custom_endpoints row + * @param {Array<{id, releasedAt?}>} rawModels + * @param {number} [nowMs] + * @returns {Array<{id, upstreamId, name, description, family, releasedAt}>} + */ +export function buildEndpointCatalogEntries(endpoint, rawModels, nowMs = Date.now()) { + const filtered = applyCatalogGuardrails(rawModels, nowMs) + return filtered.map((m) => ({ + id: customModelId(endpoint.id, m.id), + upstreamId: m.id, + name: prettyModelName(m.id), + description: `Served by ${endpoint.name}`, + family: null, + releasedAt: m.releasedAt ?? null, + })) +} + +// ═══════════════════════════════════════════════════════════════ +// Custom endpoint rows +// ═══════════════════════════════════════════════════════════════ + +/** + * List all registered custom endpoints (with plain-text keys — server-side + * use only; routes must mask before responding). + * @returns {Array} + */ +export function listCustomEndpoints() { + return db.prepare('SELECT * FROM custom_endpoints ORDER BY created_at ASC').all() +} + +/** + * Fetch one custom endpoint row by ID. + * @param {string} id + * @returns {Object|undefined} + */ +export function getCustomEndpoint(id) { + return db.prepare('SELECT * FROM custom_endpoints WHERE id = ?').get(id) +} diff --git a/server/providers/claude.js b/server/providers/claude.js index 0b4216c..041426c 100644 --- a/server/providers/claude.js +++ b/server/providers/claude.js @@ -33,9 +33,9 @@ export class ClaudeProvider extends AIProvider { static get models() { return [ - { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Best balance of speed and intelligence' }, - { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', description: 'Fastest, most cost-effective' }, - { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable, complex reasoning' }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Best balance of speed and intelligence', family: 'Sonnet' }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', description: 'Fastest, most cost-effective', family: 'Haiku' }, + { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable, complex reasoning', family: 'Opus' }, ] } @@ -255,14 +255,60 @@ export class ClaudeProvider extends AIProvider { } } + /** + * Lightweight key verification: list the model catalog. + * Costs no tokens and fails fast on an invalid key. + */ async testApiKey(apiKey) { - const Anthropic = (await import('@anthropic-ai/sdk')).default - const client = new Anthropic({ apiKey }) - await client.messages.create({ - model: 'claude-haiku-4-5', - max_tokens: 16, - messages: [{ role: 'user', content: 'Say "ok"' }], - }) + await ClaudeProvider.fetchModels(apiKey) return true } + + /** + * Live model discovery via the Anthropic model listing API. + * Returns release timestamps (created_at) for the recency guardrail. + * + * @param {string} apiKey + * @returns {Promise>} + */ + static async fetchModels(apiKey) { + const models = [] + let afterId = '' + let pages = 0 + do { + const url = new URL('https://api.anthropic.com/v1/models') + url.searchParams.set('limit', '100') + if (afterId) url.searchParams.set('after_id', afterId) + + const res = await fetch(url, { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }) + if (!res.ok) { + let detail = '' + try { + const body = await res.json() + detail = body?.error?.message || '' + } catch { /* non-JSON body */ } + throw new Error(detail || `Anthropic model listing failed with status ${res.status}`) + } + + const data = await res.json() + for (const m of data.data || []) { + const releasedAt = m.created_at ? Date.parse(m.created_at) : null + models.push({ + id: m.id, + name: m.display_name || m.id, + description: '', + releasedAt: Number.isNaN(releasedAt) ? null : releasedAt, + }) + } + afterId = data.has_more ? data.last_id : '' + pages += 1 + } while (afterId && pages < 10) + + return models + } } diff --git a/server/providers/custom.js b/server/providers/custom.js new file mode 100644 index 0000000..048a5b3 --- /dev/null +++ b/server/providers/custom.js @@ -0,0 +1,65 @@ +/** + * @fileoverview Custom endpoint provider ("Bring Your Own Model"). + * + * One instance per user-registered OpenAI-compatible endpoint + * (Ollama, LM Studio, vLLM, OpenRouter, Groq, ...). The endpoint row + * from the `custom_endpoints` table supplies the base URL, optional + * API key, and display name. + * + * Models from custom endpoints are namespaced in the global picker as + * custom:: + * The inherited _resolveModel() strips that prefix before requests. + */ + +import { OpenAICompatibleProvider } from './openai_compatible.js' + +/** Brand color used for all custom endpoint groups in the UI. */ +export const CUSTOM_ENDPOINT_COLOR = '#8B5CF6' + +/** + * Build the namespaced picker ID for a model on a custom endpoint. + * @param {string} endpointId + * @param {string} upstreamModelId + * @returns {string} + */ +export function customModelId(endpointId, upstreamModelId) { + return `custom:${endpointId}:${upstreamModelId}` +} + +/** + * Parse a namespaced custom model ID. + * @param {string} modelId + * @returns {{ endpointId: string, upstreamModelId: string }|null} + */ +export function parseCustomModelId(modelId) { + if (typeof modelId !== 'string' || !modelId.startsWith('custom:')) return null + const parts = modelId.split(':') + if (parts.length < 3) return null + return { endpointId: parts[1], upstreamModelId: parts.slice(2).join(':') } +} + +export class CustomEndpointProvider extends OpenAICompatibleProvider { + static get providerId() { return 'custom' } + static get displayName() { return 'Custom Endpoint' } + static get shortName() { return 'Custom' } + static get brandColor() { return CUSTOM_ENDPOINT_COLOR } + + static get capabilities() { + return { + streaming: true, + toolCalling: true, // degrades to plain chat when the engine rejects tools + jsonMode: true, // adaptive: json_schema → json_object → prompt + embeddings: false, + } + } + + /** + * @param {{ id: string, name: string, base_url: string, api_key?: string }} endpoint + * A row from the custom_endpoints table + */ + constructor(endpoint) { + super(endpoint.api_key || '', endpoint.base_url) + this.endpointId = endpoint.id + this.endpointName = endpoint.name + } +} diff --git a/server/providers/gemini.js b/server/providers/gemini.js index 4ed5794..e4356b9 100644 --- a/server/providers/gemini.js +++ b/server/providers/gemini.js @@ -27,9 +27,9 @@ export class GeminiProvider extends AIProvider { static get models() { return [ - { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: 'Fast and efficient — best for most tasks' }, - { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Advanced reasoning, stable and reliable' }, - { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', description: 'Budget-friendly, high-speed for simple tasks' }, + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: 'Fast and efficient — best for most tasks', family: 'Flash' }, + { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Advanced reasoning, stable and reliable', family: 'Pro' }, + { id: 'gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite', description: 'Budget-friendly, high-speed for simple tasks', family: 'Flash-Lite' }, ] } @@ -42,6 +42,10 @@ export class GeminiProvider extends AIProvider { } } + static ownsModelId(modelId) { + return /^(gemini|gemma)/i.test(modelId || '') + } + // ═══════════════════════════════════════════════════════════ // Instance implementation // ═══════════════════════════════════════════════════════════ @@ -244,11 +248,57 @@ export class GeminiProvider extends AIProvider { } while (isFunctionCall) } + /** + * Lightweight key verification: list the model catalog. + * Costs no tokens and fails fast on an invalid key. + */ async testApiKey(apiKey) { - const { GoogleGenerativeAI } = await import('@google/generative-ai') - const genAI = new GoogleGenerativeAI(apiKey) - const model = genAI.getGenerativeModel({ model: 'gemini-3.5-flash' }) - await model.generateContent('Say "ok"') + await GeminiProvider.fetchModels(apiKey) return true } + + /** + * Live model discovery via the Gemini model listing API. + * Keeps only models that support generateContent (chat-capable). + * The listing exposes no release timestamps, so releasedAt is null + * and the recency guardrail passes these models through. + * + * @param {string} apiKey + * @returns {Promise>} + */ + static async fetchModels(apiKey) { + const models = [] + let pageToken = '' + do { + const url = new URL('https://generativelanguage.googleapis.com/v1beta/models') + url.searchParams.set('key', apiKey) + url.searchParams.set('pageSize', '200') + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const res = await fetch(url) + if (!res.ok) { + let detail = '' + try { + const body = await res.json() + detail = body?.error?.message || '' + } catch { /* non-JSON body */ } + throw new Error(detail || `Gemini model listing failed with status ${res.status}`) + } + + const data = await res.json() + for (const m of data.models || []) { + const methods = m.supportedGenerationMethods || [] + if (!methods.includes('generateContent')) continue + models.push({ + id: (m.name || '').replace(/^models\//, ''), + name: m.displayName || (m.name || '').replace(/^models\//, ''), + description: (m.description || '').slice(0, 140), + releasedAt: null, + }) + } + pageToken = data.nextPageToken || '' + } while (pageToken) + + return models + } } diff --git a/server/providers/index.js b/server/providers/index.js index 65242db..ec9d4e6 100644 --- a/server/providers/index.js +++ b/server/providers/index.js @@ -2,36 +2,46 @@ * @fileoverview AI Provider Registry and Factory. * * Central entry point for the provider abstraction layer. - * Resolves model IDs to provider instances and manages the model catalog. + * Resolves model IDs to provider instances, manages the discovered + * model catalog, and orchestrates catalog refreshes. * * ┌────────────────────────────────────────────────────────────────┐ - * │ Auto-Registration Architecture │ + * │ Model resolution order (getProvider) │ * │ │ - * │ 1. Provider classes declare static metadata (providerId, │ - * │ models, configKey, etc.) │ - * │ 2. PROVIDER_CLASSES array lists all available providers │ - * │ 3. Registry auto-builds lookup maps from static metadata │ - * │ 4. Adding a new provider = 1 new file + 1 line here │ + * │ 1. `custom::` → CustomEndpointProvider │ + * │ 2. Static catalog exact match │ + * │ 3. Discovered (cached) catalog match │ + * │ 4. Provider namespace heuristics (ownsModelId) │ + * │ 5. First registered provider (backward compatibility) │ * └────────────────────────────────────────────────────────────────┘ * - * Usage: - * import { getProvider, getAvailableModels } from '../providers/index.js' - * - * const provider = getProvider('claude-sonnet-4-6') // → ClaudeProvider instance - * const provider = getProvider('gemini-3.5-flash') // → GeminiProvider instance - * const models = getAvailableModels() // → grouped model list + * Catalogs: each provider ships a small static fallback list, replaced + * by live discovery (the provider's model listing API) as soon as a key + * is verified or a refresh runs. Discovered lists persist in SQLite via + * providers/catalog.js. */ import db from '../db.js' import { GeminiProvider } from './gemini.js' import { ClaudeProvider } from './claude.js' +import { OpenAIProvider } from './openai.js' +import { CustomEndpointProvider, CUSTOM_ENDPOINT_COLOR, parseCustomModelId } from './custom.js' +import { + saveCatalog, + loadCatalog, + clearCatalog, + buildProviderCatalogEntries, + buildEndpointCatalogEntries, + listCustomEndpoints, + getCustomEndpoint, +} from './catalog.js' import logger from '../utils/logger.js' // ═══════════════════════════════════════════════════════════════ // Provider Registration // -// To add a new provider (e.g. OpenAI, Ollama): -// 1. Create server/providers/openai.js extending AIProvider +// To add a new key-based provider: +// 1. Create server/providers/.js extending AIProvider // 2. Add the class to this array // 3. Done — everything else auto-discovers it // ═══════════════════════════════════════════════════════════════ @@ -39,6 +49,7 @@ import logger from '../utils/logger.js' const PROVIDER_CLASSES = [ GeminiProvider, ClaudeProvider, + OpenAIProvider, ] // ═══════════════════════════════════════════════════════════════ @@ -50,7 +61,7 @@ const PROVIDERS = Object.fromEntries( PROVIDER_CLASSES.map(P => [P.providerId, P]) ) -/** Map: modelId → providerId (for fast model→provider resolution) */ +/** Map: modelId → providerId (static fallback catalogs) */ const MODEL_TO_PROVIDER = {} for (const ProviderClass of PROVIDER_CLASSES) { for (const model of ProviderClass.models) { @@ -67,7 +78,7 @@ const providerCache = new Map() /** * Get the API key for a given provider from the database config. - * @param {string} providerId - e.g. 'gemini', 'claude' + * @param {string} providerId - e.g. 'gemini', 'claude', 'openai' * @returns {string|null} The API key or null if not configured */ export function getApiKeyForProvider(providerId) { @@ -80,19 +91,27 @@ export function getApiKeyForProvider(providerId) { /** * Determine which provider a model ID belongs to. - * Uses exact match first, then prefix-based fallback. + * Checks static catalogs, then discovered catalogs, then namespace + * heuristics. * * @param {string} modelId - e.g. 'gemini-3.5-flash' or 'claude-sonnet-4-6' * @returns {string} Provider ID */ export function getProviderIdForModel(modelId) { - // Exact match from the model catalog + // Exact match from the static model catalogs if (MODEL_TO_PROVIDER[modelId]) { return MODEL_TO_PROVIDER[modelId] } - // Prefix-based fallback: check if model ID starts with any registered provider ID + // Match against discovered (cached) catalogs + for (const ProviderClass of PROVIDER_CLASSES) { + const cached = loadCatalog(ProviderClass.providerId) + if (cached?.models?.some(m => m.id === modelId)) { + return ProviderClass.providerId + } + } + // Namespace heuristics (e.g. 'gpt-*' → openai, 'gemma-*' → gemini) for (const ProviderClass of PROVIDER_CLASSES) { - if (modelId.startsWith(ProviderClass.providerId)) { + if (ProviderClass.ownsModelId(modelId)) { return ProviderClass.providerId } } @@ -102,13 +121,32 @@ export function getProviderIdForModel(modelId) { /** * Get a provider instance for a given model ID. - * Resolves the correct provider class and API key, returns a ready-to-use instance. + * Resolves the correct provider class and API key, returns a ready-to-use + * instance. Custom endpoint models (`custom::`) + * resolve to a CustomEndpointProvider bound to that endpoint. * - * @param {string} modelId - The model ID (e.g. 'gemini-3.5-flash', 'claude-sonnet-4-6') + * @param {string} modelId - The model ID * @returns {AIProvider} A provider instance - * @throws {Error} If the provider's API key is not configured + * @throws {Error} If the provider's API key or endpoint is not configured */ export function getProvider(modelId) { + // Custom endpoint models + const custom = parseCustomModelId(modelId) + if (custom) { + const endpoint = getCustomEndpoint(custom.endpointId) + if (!endpoint) { + throw new Error('Custom endpoint not found. It may have been removed — pick another model in Settings.') + } + const cacheKey = `custom:${endpoint.id}:${endpoint.base_url}:${(endpoint.api_key || '').slice(0, 8)}` + if (providerCache.has(cacheKey)) { + return providerCache.get(cacheKey) + } + const instance = new CustomEndpointProvider(endpoint) + providerCache.set(cacheKey, instance) + logger.info(`[providers] Created custom endpoint provider for "${endpoint.name}"`) + return instance + } + const providerId = getProviderIdForModel(modelId) const ProviderClass = PROVIDERS[providerId] @@ -139,7 +177,7 @@ export function getProvider(modelId) { * Get a provider instance by provider ID (not model ID). * Used for key testing where we know the provider but not a specific model. * - * @param {string} providerId - e.g. 'gemini', 'claude' + * @param {string} providerId - e.g. 'gemini', 'claude', 'openai' * @param {string} apiKey - The API key to use * @returns {AIProvider} A provider instance (not cached) */ @@ -151,13 +189,120 @@ export function getProviderByIdWithKey(providerId, apiKey) { return new ProviderClass(apiKey) } +// ═══════════════════════════════════════════════════════════════ +// Catalog refresh orchestration +// ═══════════════════════════════════════════════════════════════ + +/** + * Refresh the discovered model catalog for one cloud provider. + * Queries the provider's model listing API, applies the guardrails + * (modality, 1-year recency, alias dedupe), and persists the result. + * + * @param {string} providerId + * @param {string} [apiKeyOverride] - Key to use instead of the stored one + * @returns {Promise>} The refreshed catalog entries + * @throws {Error} When no key is configured or discovery fails + */ +export async function refreshProviderCatalog(providerId, apiKeyOverride) { + const ProviderClass = PROVIDERS[providerId] + if (!ProviderClass) { + throw new Error(`Unknown provider: "${providerId}"`) + } + const apiKey = apiKeyOverride || getApiKeyForProvider(providerId) + if (!apiKey) { + throw new Error(`${ProviderClass.displayName} API key not configured.`) + } + + const rawModels = await ProviderClass.fetchModels(apiKey) + const entries = buildProviderCatalogEntries(providerId, rawModels) + saveCatalog(providerId, entries) + logger.info(`[providers] Discovered ${entries.length} ${ProviderClass.displayName} models`) + return entries +} + +/** + * Refresh the discovered model catalog for one custom endpoint. + * + * @param {Object} endpoint - A custom_endpoints row + * @returns {Promise>} The refreshed catalog entries + * @throws {Error} When the endpoint is unreachable + */ +export async function refreshEndpointCatalog(endpoint) { + const rawModels = await CustomEndpointProvider.fetchModels(endpoint.api_key || '', endpoint.base_url) + const entries = buildEndpointCatalogEntries(endpoint, rawModels) + saveCatalog(`custom:${endpoint.id}`, entries) + logger.info(`[providers] Discovered ${entries.length} models on custom endpoint "${endpoint.name}"`) + return entries +} + +/** + * Refresh every configured provider and custom endpoint. + * Failures are collected per catalog instead of aborting the sweep. + * + * @returns {Promise<{ refreshed: string[], errors: Object }>} + */ +export async function refreshAllCatalogs() { + const refreshed = [] + const errors = {} + + for (const ProviderClass of PROVIDER_CLASSES) { + const providerId = ProviderClass.providerId + if (!getApiKeyForProvider(providerId)) continue + try { + await refreshProviderCatalog(providerId) + refreshed.push(providerId) + } catch (err) { + errors[providerId] = err.message + logger.warn(`[providers] Catalog refresh failed for ${providerId}: ${err.message}`) + } + } + + for (const endpoint of listCustomEndpoints()) { + const catalogId = `custom:${endpoint.id}` + try { + await refreshEndpointCatalog(endpoint) + refreshed.push(catalogId) + } catch (err) { + errors[catalogId] = err.message + logger.warn(`[providers] Catalog refresh failed for endpoint "${endpoint.name}": ${err.message}`) + } + } + + return { refreshed, errors } +} + +/** + * Clean up after a provider's API key is removed: + * drop its discovered catalog and cached provider instances. + * @param {string} providerId + */ +export function handleProviderKeyRemoved(providerId) { + clearCatalog(providerId) + for (const key of providerCache.keys()) { + if (key.startsWith(`${providerId}:`)) providerCache.delete(key) + } +} + +/** + * Clean up after a custom endpoint is removed or edited: + * drop its discovered catalog and cached provider instances. + * @param {string} endpointId + */ +export function handleEndpointRemoved(endpointId) { + clearCatalog(`custom:${endpointId}`) + for (const key of providerCache.keys()) { + if (key.startsWith(`custom:${endpointId}:`)) providerCache.delete(key) + } +} + // ═══════════════════════════════════════════════════════════════ // Discovery API (for routes and frontend) // ═══════════════════════════════════════════════════════════════ /** - * Get all available models based on which API keys are configured. - * Returns models grouped by provider with metadata. + * Get all available models grouped by provider / custom endpoint. + * Uses the discovered catalog when present, falling back to each + * provider's static list. Custom endpoints appear as their own groups. * * @returns {Array<{ provider: Object, models: Array }>} */ @@ -165,21 +310,42 @@ export function getAvailableModels() { const result = [] for (const ProviderClass of PROVIDER_CLASSES) { - const apiKey = getApiKeyForProvider(ProviderClass.providerId) - if (apiKey) { - result.push({ - provider: { - id: ProviderClass.providerId, - name: ProviderClass.displayName, - color: ProviderClass.brandColor, - }, - models: ProviderClass.models.map(m => ({ - ...m, - providerId: ProviderClass.providerId, - providerName: ProviderClass.displayName, - })), - }) - } + const providerId = ProviderClass.providerId + const apiKey = getApiKeyForProvider(providerId) + if (!apiKey) continue + + const cached = loadCatalog(providerId) + const models = (cached?.models?.length ? cached.models : ProviderClass.models) + + result.push({ + provider: { + id: providerId, + name: ProviderClass.displayName, + color: ProviderClass.brandColor, + }, + models: models.map(m => ({ + ...m, + providerId, + providerName: ProviderClass.displayName, + })), + }) + } + + for (const endpoint of listCustomEndpoints()) { + const cached = loadCatalog(`custom:${endpoint.id}`) + result.push({ + provider: { + id: `custom:${endpoint.id}`, + name: endpoint.name, + color: CUSTOM_ENDPOINT_COLOR, + isCustom: true, + }, + models: (cached?.models || []).map(m => ({ + ...m, + providerId: `custom:${endpoint.id}`, + providerName: endpoint.name, + })), + }) } return result @@ -187,7 +353,7 @@ export function getAvailableModels() { /** * Get the configured status of each provider's API key. - * @returns {Object} e.g. { gemini: true, claude: false } + * @returns {Object} e.g. { gemini: true, claude: false, openai: false } */ export function getApiKeyStatus() { const status = {} @@ -227,6 +393,16 @@ export function getApiKeyFields() { return PROVIDER_CLASSES.map(P => P.configKey) } +/** + * Map a config key (e.g. 'openai_api_key') back to its provider ID. + * @param {string} configKey + * @returns {string|null} + */ +export function getProviderIdForConfigKey(configKey) { + const ProviderClass = PROVIDER_CLASSES.find(P => P.configKey === configKey) + return ProviderClass ? ProviderClass.providerId : null +} + /** * Seed API keys from environment variables for all registered providers. * Called during database initialization. diff --git a/server/providers/model_filter.js b/server/providers/model_filter.js new file mode 100644 index 0000000..e1e290f --- /dev/null +++ b/server/providers/model_filter.js @@ -0,0 +1,183 @@ +/** + * @fileoverview Model catalog guardrails. + * + * Pure functions that filter raw provider model listings down to the + * set of models the UI should offer. Three passes run in order: + * + * 1. Modality filter — keep chat/reasoning models, drop embeddings, + * audio, image/video generation, and moderation. + * 2. Recency filter — drop models released/updated over 1 year ago + * (models without a timestamp pass through). + * 3. Alias dedupe — collapse pinned snapshots (gpt-4o-2024-08-06) + * into their floating alias (gpt-4o) when both + * appear in the listing. + * + * No database or network access here — the catalog module composes + * these with provider fetchers. This keeps the guardrails unit-testable. + */ + +/** The recency window: 1 year in milliseconds. */ +export const RECENCY_WINDOW_MS = 365 * 24 * 60 * 60 * 1000 + +/** + * Identifier substrings that mark a model as non-conversational. + * Matching is case-insensitive against the raw model ID. + */ +const NON_CHAT_PATTERNS = [ + // Embeddings + /embed/i, + // Audio: transcription, speech synthesis, realtime voice + /whisper/i, + /\btts\b|-tts/i, + /transcribe/i, + /-audio/i, + /realtime/i, + /speech/i, + // Image / video generation + /dall-?e/i, + /imagen/i, + /veo/i, + /-image/i, + /image-generation/i, + // Moderation & safety classifiers + /moderation/i, + /guard/i, + // Legacy completion-only engines + /babbage/i, + /davinci/i, + /-instruct/i, + // Search / retrieval helper models + /search-preview/i, + // Gemini attributed question answering (not a chat model) + /\baqa\b/i, +] + +/** + * Check if a model ID looks like an interactive chat/reasoning model. + * @param {string} modelId - Raw model identifier from the provider + * @returns {boolean} True when the model is a chat model + */ +export function isChatModel(modelId) { + if (!modelId) return false + return !NON_CHAT_PATTERNS.some((re) => re.test(modelId)) +} + +/** + * Check if a release timestamp falls inside the recency window. + * Models without a timestamp pass the check — several providers + * (e.g. the Gemini listing API) do not expose release dates, and + * dropping every undated model would empty the catalog. + * + * @param {number|null|undefined} releasedAtMs - Release time (ms epoch), or null + * @param {number} [nowMs] - Current time (ms epoch); defaults to Date.now() + * @returns {boolean} True when the model is recent enough to show + */ +export function isWithinRecencyWindow(releasedAtMs, nowMs = Date.now()) { + if (releasedAtMs === null || releasedAtMs === undefined) return true + return nowMs - releasedAtMs <= RECENCY_WINDOW_MS +} + +/** Matches pinned snapshot suffixes: -2024-08-06, -20250219, -0125, @001 */ +const SNAPSHOT_SUFFIX_RE = /[-@](\d{4}-\d{2}-\d{2}|\d{8}|\d{3,4})$/ + +/** + * Strip a pinned snapshot suffix from a model ID. + * @param {string} modelId + * @returns {string} The base alias (unchanged when no suffix matches) + */ +export function baseAliasOf(modelId) { + return modelId.replace(SNAPSHOT_SUFFIX_RE, '') +} + +/** + * Collapse alias duplicates in a model list. + * + * When the listing contains both a floating alias ("gpt-4o") and its + * pinned snapshots ("gpt-4o-2024-08-06"), only the floating alias + * stays. A snapshot with no matching alias stays as-is. Exact + * duplicate IDs also collapse to one entry. + * + * @param {Array<{id: string}>} models + * @returns {Array<{id: string}>} The deduplicated list (original order kept) + */ +export function dedupeAliases(models) { + const ids = new Set(models.map((m) => m.id)) + const seen = new Set() + const result = [] + for (const model of models) { + if (seen.has(model.id)) continue + seen.add(model.id) + const base = baseAliasOf(model.id) + // Drop this snapshot when its floating alias is also present + if (base !== model.id && ids.has(base)) continue + result.push(model) + } + return result +} + +/** + * Run all three guardrail passes over a raw model list. + * + * @param {Array<{id: string, releasedAt?: number|null}>} models - Raw models. + * `releasedAt` is a ms-epoch release/update timestamp, or null when unknown. + * @param {number} [nowMs] - Current time (ms epoch) for the recency check + * @returns {Array} The filtered, deduplicated model list + */ +export function applyCatalogGuardrails(models, nowMs = Date.now()) { + const filtered = models.filter( + (m) => isChatModel(m.id) && isWithinRecencyWindow(m.releasedAt, nowMs) + ) + return dedupeAliases(filtered) +} + +/** + * Infer the marketing family of a model from its ID. + * Used for display grouping/labels in pickers. + * + * @param {string} providerId - 'gemini' | 'claude' | 'openai' | other + * @param {string} modelId + * @returns {string|null} Family label (e.g. 'Flash', 'Sonnet', 'Reasoning') or null + */ +export function inferModelFamily(providerId, modelId) { + const id = (modelId || '').toLowerCase() + if (providerId === 'gemini') { + if (id.includes('flash-lite')) return 'Flash-Lite' + if (id.includes('flash')) return 'Flash' + if (id.includes('pro')) return 'Pro' + return null + } + if (providerId === 'claude') { + if (id.includes('sonnet')) return 'Sonnet' + if (id.includes('haiku')) return 'Haiku' + if (id.includes('opus')) return 'Opus' + return null + } + if (providerId === 'openai') { + if (id.includes('mini') || id.includes('nano')) return 'Mini' + if (/^o\d/.test(id)) return 'Reasoning' + if (id.startsWith('gpt') || id.startsWith('chatgpt')) return 'Flagship' + return null + } + return null +} + +/** + * Build a human-readable display name from a raw model ID. + * 'gpt-4o' → 'GPT 4o', 'llama3.2:3b' → 'Llama3.2 3b' + * + * @param {string} modelId + * @returns {string} + */ +export function prettyModelName(modelId) { + if (!modelId) return '' + const cleaned = modelId.replace(/^models\//, '') + return cleaned + .split(/[-_:/]/) + .filter(Boolean) + .map((part) => { + if (/^gpt/i.test(part)) return part.toUpperCase() + if (/^\d/.test(part)) return part + return part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(' ') +} diff --git a/server/providers/openai.js b/server/providers/openai.js new file mode 100644 index 0000000..17bbff9 --- /dev/null +++ b/server/providers/openai.js @@ -0,0 +1,55 @@ +/** + * @fileoverview OpenAI provider (api.openai.com). + * + * All wire-format logic lives in OpenAICompatibleProvider — this class + * adds the branding, config metadata, and the static fallback catalog + * shown before the first live model discovery completes. + */ + +import { OpenAICompatibleProvider } from './openai_compatible.js' + +export class OpenAIProvider extends OpenAICompatibleProvider { + // ═══════════════════════════════════════════════════════════ + // Static metadata (self-describing) + // ═══════════════════════════════════════════════════════════ + + static get providerId() { return 'openai' } + static get displayName() { return 'OpenAI' } + static get shortName() { return 'OpenAI' } + static get brandColor() { return '#10A37F' } + static get configKey() { return 'openai_api_key' } + static get envKey() { return 'OPENAI_API_KEY' } + static get keyPlaceholder() { return 'sk-...' } + static get keyHelpUrl() { return 'https://platform.openai.com/api-keys' } + static get keyHelpLabel() { return 'OpenAI Platform' } + + /** + * Static fallback catalog. Live discovery (GET /v1/models) replaces + * this list as soon as a key is verified. + */ + static get models() { + return [ + { id: 'gpt-5.1', name: 'GPT-5.1', description: 'Flagship — strongest general intelligence', family: 'Flagship' }, + { id: 'gpt-5.1-mini', name: 'GPT-5.1 Mini', description: 'Fast and cost-effective for everyday tasks', family: 'Mini' }, + { id: 'o4-mini', name: 'o4 Mini', description: 'Efficient reasoning model', family: 'Reasoning' }, + ] + } + + static get capabilities() { + return { + streaming: true, + toolCalling: true, + jsonMode: true, + embeddings: false, + } + } + + static ownsModelId(modelId) { + return /^(gpt|o\d|chatgpt|codex)/i.test(modelId || '') + } + + /** Reasoning models on api.openai.com require the modern parameter. */ + get maxTokensParam() { + return 'max_completion_tokens' + } +} diff --git a/server/providers/openai_compatible.js b/server/providers/openai_compatible.js new file mode 100644 index 0000000..5d180a7 --- /dev/null +++ b/server/providers/openai_compatible.js @@ -0,0 +1,407 @@ +/** + * @fileoverview OpenAI-compatible provider core. + * + * Speaks the standard OpenAI wire format (`/chat/completions`, `/models`) + * over plain fetch — no SDK dependency. Two concrete providers build on + * this class: + * + * - OpenAIProvider → api.openai.com (branded, static metadata) + * - CustomEndpointProvider → user-configured servers (Ollama, LM Studio, + * vLLM, OpenRouter, Groq, ...) + * + * Structured output (generateJSON) adapts to the server's capability: + * + * json_schema → json_object → prompt-enforced JSON + * + * The first mode that succeeds is cached per (baseUrl, model), so later + * calls skip the failing modes. + */ + +import { AIProvider } from './base.js' +import logger from '../utils/logger.js' + +/** Ordered list of structured-output modes, most strict first. */ +const JSON_MODES = ['json_schema', 'json_object', 'prompt'] + +/** Cache: `${baseUrl}|${model}` → index into JSON_MODES that last worked. */ +const jsonModeCache = new Map() + +export class OpenAICompatibleProvider extends AIProvider { + // ═══════════════════════════════════════════════════════════ + // Static metadata — subclasses override the branding + // ═══════════════════════════════════════════════════════════ + + static get apiBaseUrl() { return 'https://api.openai.com/v1' } + + /** + * @param {string} apiKey - Bearer token (may be empty for local servers) + * @param {string} [baseUrl] - Override the API base URL + */ + constructor(apiKey, baseUrl) { + super(apiKey) + this.baseUrl = (baseUrl || new.target.apiBaseUrl).replace(/\/+$/, '') + } + + // ═══════════════════════════════════════════════════════════ + // HTTP plumbing + // ═══════════════════════════════════════════════════════════ + + _headers() { + const headers = { 'Content-Type': 'application/json' } + if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}` + return headers + } + + /** + * Name of the max-tokens request parameter. + * Local engines expect the classic `max_tokens`; the branded OpenAI + * provider overrides this with `max_completion_tokens` (required by + * reasoning models). + */ + get maxTokensParam() { + return 'max_tokens' + } + + /** + * Resolve the model ID to send upstream. + * Strips the `custom::` namespace prefix that the global + * model picker uses for custom endpoint models. + */ + _resolveModel(model) { + const id = model || this.constructor.defaultModel + if (id && id.startsWith('custom:')) { + return id.split(':').slice(2).join(':') + } + return id + } + + /** + * POST /chat/completions (non-streaming). + * @returns {Promise} The parsed response body + * @throws {Error} With the server's error message on failure + */ + async _chatCompletion(body) { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: this._headers(), + body: JSON.stringify(body), + }) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() + } + + // ═══════════════════════════════════════════════════════════ + // AIProvider implementation + // ═══════════════════════════════════════════════════════════ + + async generateText(prompt, options = {}) { + const messages = [] + if (options.systemPrompt) messages.push({ role: 'system', content: options.systemPrompt }) + messages.push({ role: 'user', content: prompt }) + + const body = { + model: this._resolveModel(options.model), + messages, + [this.maxTokensParam]: options.maxOutputTokens ?? 8192, + } + if (options.temperature !== undefined) body.temperature = options.temperature + + const data = await this._chatCompletion(body) + return data.choices?.[0]?.message?.content ?? '' + } + + /** + * Structured JSON generation with adaptive downgrade. + * Tries `json_schema`, then `json_object`, then prompt-only enforcement. + * A mode fails on an API error or an unparseable response; the next + * mode then runs. The first working mode is cached per (baseUrl, model). + */ + async generateJSON(prompt, schema, options = {}) { + const model = this._resolveModel(options.model) + const cacheKey = `${this.baseUrl}|${model}` + const startIndex = jsonModeCache.get(cacheKey) ?? 0 + + let lastError = null + for (let i = startIndex; i < JSON_MODES.length; i++) { + const mode = JSON_MODES[i] + try { + const result = await this._generateJSONWithMode(mode, prompt, schema, model, options) + jsonModeCache.set(cacheKey, i) + return result + } catch (err) { + lastError = err + logger.warn( + `[OpenAICompatibleProvider] JSON mode "${mode}" failed for ${model} at ${this.baseUrl}: ${err.message}` + ) + } + } + throw lastError || new Error('Structured JSON generation failed') + } + + async _generateJSONWithMode(mode, prompt, schema, model, options) { + let systemPrompt = options.systemPrompt || '' + systemPrompt += + '\n\nIMPORTANT: Respond with valid JSON only. No markdown, no code fences, no explanation — just raw JSON.' + + const body = { + model, + [this.maxTokensParam]: options.maxOutputTokens ?? 8192, + } + if (options.temperature !== undefined) body.temperature = options.temperature + + // OpenAI json_schema mode requires an object at the schema root. + // Wrap array/scalar roots into { items: ... } and unwrap after. + const needsWrap = schema && schema.type !== 'object' + + if (mode === 'json_schema') { + const effectiveSchema = needsWrap + ? { type: 'object', properties: { items: schema }, required: ['items'] } + : (schema || { type: 'object' }) + body.response_format = { + type: 'json_schema', + json_schema: { name: 'structured_response', schema: effectiveSchema }, + } + if (needsWrap) { + systemPrompt += '\n\nReturn a JSON object with a single "items" key holding the requested data.' + } + } else { + if (mode === 'json_object') { + body.response_format = { type: 'json_object' } + } + if (schema) { + systemPrompt += `\n\nThe response must conform to this JSON schema:\n${JSON.stringify(schema, null, 2)}` + if (schema.type === 'array') { + systemPrompt += '\n\nReturn the JSON array directly (or an object with an "items" array).' + } + } + } + + body.messages = [ + { role: 'system', content: systemPrompt.trim() }, + { role: 'user', content: prompt }, + ] + + const data = await this._chatCompletion(body) + const text = data.choices?.[0]?.message?.content ?? '' + const parsed = parseJSONResponse(text) + + // Unwrap { items: [...] } when the root schema was not an object + if (needsWrap && parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'items' in parsed) { + return parsed.items + } + return parsed + } + + async *streamChat(systemPrompt, history, message, options = {}) { + const messages = [ + { role: 'system', content: systemPrompt }, + ...mapHistory(history), + { role: 'user', content: message }, + ] + + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: this._headers(), + body: JSON.stringify({ + model: this._resolveModel(options.model), + messages, + [this.maxTokensParam]: options.maxOutputTokens ?? 8192, + temperature: options.temperature ?? 0.5, + stream: true, + }), + }) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const payload = line.slice(6).trim() + if (payload === '[DONE]') return + try { + const parsed = JSON.parse(payload) + const text = parsed.choices?.[0]?.delta?.content + if (text) yield { type: 'text', text } + } catch { + // Skip malformed SSE lines + } + } + } + } + + async *streamChatWithTools(systemPrompt, history, message, tools, toolExecutor, options = {}) { + const model = this._resolveModel(options.model) + const openaiTools = tools.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description, parameters: t.parameters }, + })) + + const messages = [ + { role: 'system', content: systemPrompt }, + ...mapHistory(history), + { role: 'user', content: message }, + ] + + let firstTurn = true + let continueLoop = true + + while (continueLoop) { + continueLoop = false + + let data + try { + data = await this._chatCompletion({ + model, + messages, + tools: openaiTools, + [this.maxTokensParam]: options.maxOutputTokens ?? 8192, + temperature: options.temperature ?? 0.5, + }) + } catch (err) { + // Some local engines reject the `tools` parameter entirely. + // Degrade gracefully to a plain streaming chat on the first turn. + if (firstTurn && /tool|function/i.test(err.message || '')) { + logger.warn( + `[OpenAICompatibleProvider] ${this.baseUrl} rejected tools (${err.message}). Falling back to plain chat.` + ) + yield* this.streamChat(systemPrompt, history, message, options) + return + } + throw err + } + firstTurn = false + + const choice = data.choices?.[0] + const msg = choice?.message || {} + + if (msg.content) { + yield { type: 'text', text: msg.content } + } + + const toolCalls = msg.tool_calls || [] + if (toolCalls.length > 0) { + messages.push({ role: 'assistant', content: msg.content ?? null, tool_calls: toolCalls }) + + for (const call of toolCalls) { + const name = call.function?.name + yield { type: 'tool', name } + let args = {} + try { + args = JSON.parse(call.function?.arguments || '{}') + } catch { + // Malformed arguments from the model — execute with empty args + } + const result = await toolExecutor(name, args) + messages.push({ + role: 'tool', + tool_call_id: call.id, + content: JSON.stringify(result), + }) + } + continueLoop = true + } + } + } + + /** + * Verify the credential/endpoint by listing models. + * Cheap (no token spend) and works on every OpenAI-compatible server. + */ + async testApiKey(apiKey) { + await this.constructor.fetchModels(apiKey, this.baseUrl) + return true + } + + /** + * Ping this instance's endpoint and return its live model list. + * @returns {Promise>} + */ + async listModels() { + return this.constructor.fetchModels(this.apiKey, this.baseUrl) + } + + /** + * GET /models from an OpenAI-compatible server. + * + * @param {string} apiKey - Bearer token (may be empty) + * @param {string} [baseUrl] - The server base URL + * @returns {Promise>} Raw models + * @throws {Error} With a connectivity-friendly message on failure + */ + static async fetchModels(apiKey, baseUrl) { + const base = (baseUrl || this.apiBaseUrl).replace(/\/+$/, '') + const headers = {} + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + + let res + try { + res = await fetch(`${base}/models`, { headers }) + } catch (err) { + throw new Error(`Could not reach ${base} — ${err.message}`, { cause: err }) + } + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + + const data = await res.json() + const rawModels = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [] + return rawModels + .filter((m) => m && (m.id || m.name)) + .map((m) => ({ + id: m.id || m.name, + // OpenAI-style `created` is unix seconds + releasedAt: typeof m.created === 'number' ? m.created * 1000 : null, + })) + } +} + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +/** Map internal chat roles ('ai'/'model') to OpenAI roles. */ +function mapHistory(history) { + return (history || []).map((msg) => ({ + role: msg.role === 'ai' || msg.role === 'model' ? 'assistant' : 'user', + content: msg.content, + })) +} + +/** Parse a JSON response, stripping markdown code fences if present. */ +function parseJSONResponse(text) { + const cleaned = String(text) + .replace(/^```(?:json)?\s*\n?/i, '') + .replace(/\n?```\s*$/i, '') + .trim() + return JSON.parse(cleaned) +} + +/** Pull a useful error message out of a failed HTTP response. */ +async function extractErrorMessage(res) { + let detail = '' + try { + const body = await res.json() + detail = body?.error?.message || body?.message || '' + } catch { + // Non-JSON error body + } + return detail || `Request failed with status ${res.status}` +} + +/** Test-only: reset the structured-output mode cache. */ +export function _resetJsonModeCache() { + jsonModeCache.clear() +} diff --git a/server/routes/config.js b/server/routes/config.js index 0157f2f..74e9d49 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -6,13 +6,20 @@ import { getProviderByIdWithKey, getProviderDefinitions, getApiKeyFields, + getProviderIdForConfigKey, + refreshProviderCatalog, + refreshAllCatalogs, + handleProviderKeyRemoved, } from '../providers/index.js' +import { maskSecret, isMaskedValue } from '../utils/mask.js' +import logger from '../utils/logger.js' const router = Router() /** * GET /api/config * Returns all config values (with API keys masked) and provider status. + * Keys are never returned in plain text — only as '••••' + last 4 chars. */ router.get('/', (req, res) => { const rows = db.prepare('SELECT key, value FROM config').all() @@ -20,8 +27,7 @@ router.get('/', (req, res) => { const config = {} for (const row of rows) { if (apiKeyFields.includes(row.key)) { - // Mask the key for client display - config[row.key] = row.value ? `${row.value.slice(0, 8)}...${row.value.slice(-4)}` : '' + config[row.key] = maskSecret(row.value) } else { config[row.key] = row.value } @@ -36,29 +42,49 @@ router.get('/', (req, res) => { /** * PUT /api/config * Update configuration values. - * Accepts any key-value pairs. API key fields are handled alongside other config. + * API key fields get special handling: + * - Masked values ('••••1234') are ignored so a form save never + * overwrites a real key with its own mask. + * - Empty values remove the key and drop that provider's discovered + * catalog + cached instances (safe deactivation). */ router.put('/', (req, res) => { + const apiKeyFields = getApiKeyFields() const upsert = db.prepare(` INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `) + const removedProviders = [] const transaction = db.transaction(() => { for (const [key, value] of Object.entries(req.body)) { - upsert.run(key, String(value)) + const strValue = String(value ?? '') + if (apiKeyFields.includes(key)) { + if (isMaskedValue(strValue)) continue + if (strValue === '') { + const providerId = getProviderIdForConfigKey(key) + if (providerId) removedProviders.push(providerId) + } + } + upsert.run(key, strValue) } }) transaction() + for (const providerId of removedProviders) { + handleProviderKeyRemoved(providerId) + } res.json({ success: true }) }) /** * POST /api/config/test-key - * Test if an API key is valid for a specific provider. - * Body: { key: string, provider: 'gemini' | 'claude' | ... } + * Verify an API key against the provider's API before saving. + * On success the key is saved and that provider's model catalog is + * refreshed immediately (live discovery). Invalid keys are never saved. + * + * Body: { key: string, provider: 'gemini' | 'claude' | 'openai' } */ router.post('/test-key', async (req, res) => { const { key, provider = 'gemini' } = req.body @@ -83,7 +109,17 @@ router.post('/test-key', async (req, res) => { ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `).run(configKey, key) - res.json({ valid: true }) + // Discover this provider's live model catalog right away. + // The key is already verified — a discovery hiccup must not fail the save. + let modelsDiscovered = 0 + try { + const models = await refreshProviderCatalog(provider, key) + modelsDiscovered = models.length + } catch (err) { + logger.warn(`[config] Model discovery after key save failed for ${provider}: ${err.message}`) + } + + res.json({ valid: true, modelsDiscovered }) } catch (err) { res.status(400).json({ valid: false, message: err.message || 'Invalid API key' }) } @@ -91,8 +127,9 @@ router.post('/test-key', async (req, res) => { /** * GET /api/config/available-models - * Returns all available models based on configured API keys, grouped by provider. - * Also returns full provider metadata for the settings UI. + * Returns all available models grouped by provider (discovered catalog + * when present, static fallback otherwise), plus custom endpoint groups + * and full provider metadata for the settings UI. */ router.get('/available-models', (req, res) => { const models = getAvailableModels() @@ -100,4 +137,16 @@ router.get('/available-models', (req, res) => { res.json({ providers, groups: models }) }) +/** + * POST /api/config/refresh-models + * Re-sync the model catalog with every configured provider and custom + * endpoint. Per-catalog failures are reported without aborting the sweep. + */ +router.post('/refresh-models', async (req, res) => { + const { refreshed, errors } = await refreshAllCatalogs() + const models = getAvailableModels() + const providers = getProviderDefinitions() + res.json({ refreshed, errors, providers, groups: models }) +}) + export default router diff --git a/server/routes/endpoints.js b/server/routes/endpoints.js new file mode 100644 index 0000000..87fcd23 --- /dev/null +++ b/server/routes/endpoints.js @@ -0,0 +1,208 @@ +/** + * @fileoverview Custom endpoint routes ("Bring Your Own Model"). + * + * CRUD for user-registered OpenAI-compatible endpoints (Ollama, + * LM Studio, vLLM, OpenRouter, Groq, ...). Every save verifies + * connectivity first and discovers the endpoint's model list. + * API keys are always masked in responses. + */ + +import { Router } from 'express' +import { randomUUID } from 'crypto' +import db from '../db.js' +import { CustomEndpointProvider } from '../providers/custom.js' +import { getCustomEndpoint, listCustomEndpoints, loadCatalog } from '../providers/catalog.js' +import { refreshEndpointCatalog, handleEndpointRemoved } from '../providers/index.js' +import { maskSecret, isMaskedValue } from '../utils/mask.js' + +const router = Router() + +/** Serialize an endpoint row for the client (key masked, models attached). */ +function toClientEndpoint(row) { + const catalog = loadCatalog(`custom:${row.id}`) + return { + id: row.id, + name: row.name, + baseUrl: row.base_url, + apiKeyMasked: maskSecret(row.api_key), + hasApiKey: !!row.api_key, + models: catalog?.models || [], + modelsFetchedAt: catalog?.fetchedAt || null, + createdAt: row.created_at, + } +} + +/** Validate and normalize a base URL. Returns null when invalid. */ +function normalizeBaseUrl(baseUrl) { + if (typeof baseUrl !== 'string' || !baseUrl.trim()) return null + const trimmed = baseUrl.trim().replace(/\/+$/, '') + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null + return trimmed + } catch { + return null + } +} + +/** + * GET /api/endpoints + * List all custom endpoints with masked keys and cached models. + */ +router.get('/', (req, res) => { + res.json(listCustomEndpoints().map(toClientEndpoint)) +}) + +/** + * POST /api/endpoints/test + * Ping an endpoint without saving it. + * Body: { baseUrl, apiKey?, endpointId? } + * When endpointId is set and apiKey is omitted/masked, the stored key + * is used (lets the UI re-test a saved endpoint). + */ +router.post('/test', async (req, res) => { + const { baseUrl, apiKey, endpointId } = req.body + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized) { + return res.status(400).json({ ok: false, message: 'A valid http(s) Base URL is required' }) + } + + let effectiveKey = typeof apiKey === 'string' && !isMaskedValue(apiKey) ? apiKey : '' + if (!effectiveKey && endpointId) { + const existing = getCustomEndpoint(endpointId) + if (existing) effectiveKey = existing.api_key || '' + } + + try { + const models = await CustomEndpointProvider.fetchModels(effectiveKey, normalized) + res.json({ ok: true, modelCount: models.length }) + } catch (err) { + res.status(400).json({ ok: false, message: err.message || 'Could not reach the endpoint' }) + } +}) + +/** + * POST /api/endpoints + * Register a custom endpoint. Connectivity is verified and the model + * list discovered before anything is saved. + * Body: { name, baseUrl, apiKey? } + */ +router.post('/', async (req, res) => { + const { name, baseUrl, apiKey } = req.body + if (typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ message: 'A name is required' }) + } + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized) { + return res.status(400).json({ message: 'A valid http(s) Base URL is required' }) + } + const key = typeof apiKey === 'string' && !isMaskedValue(apiKey) ? apiKey.trim() : '' + + const endpoint = { + id: randomUUID(), + name: name.trim(), + base_url: normalized, + api_key: key, + } + + try { + // Pre-flight: the endpoint must respond before we save it + await CustomEndpointProvider.fetchModels(key, normalized) + } catch (err) { + return res.status(400).json({ message: err.message || 'Could not reach the endpoint' }) + } + + db.prepare(` + INSERT INTO custom_endpoints (id, name, base_url, api_key) + VALUES (?, ?, ?, ?) + `).run(endpoint.id, endpoint.name, endpoint.base_url, endpoint.api_key) + + try { + await refreshEndpointCatalog(endpoint) + } catch { + // Discovery hiccup after a successful pre-flight — the endpoint is + // saved; the user can hit Refresh Models later. + } + + res.status(201).json(toClientEndpoint(getCustomEndpoint(endpoint.id))) +}) + +/** + * PUT /api/endpoints/:id + * Edit a custom endpoint. Omitted or masked apiKey keeps the stored key. + * Connectivity is re-verified and the model list re-discovered. + * Body: { name?, baseUrl?, apiKey? } + */ +router.put('/:id', async (req, res) => { + const existing = getCustomEndpoint(req.params.id) + if (!existing) { + return res.status(404).json({ message: 'Endpoint not found' }) + } + + const { name, baseUrl, apiKey } = req.body + const nextName = typeof name === 'string' && name.trim() ? name.trim() : existing.name + const nextUrl = baseUrl !== undefined ? normalizeBaseUrl(baseUrl) : existing.base_url + if (!nextUrl) { + return res.status(400).json({ message: 'A valid http(s) Base URL is required' }) + } + let nextKey = existing.api_key || '' + if (typeof apiKey === 'string' && !isMaskedValue(apiKey)) { + nextKey = apiKey.trim() + } + + try { + await CustomEndpointProvider.fetchModels(nextKey, nextUrl) + } catch (err) { + return res.status(400).json({ message: err.message || 'Could not reach the endpoint' }) + } + + db.prepare(` + UPDATE custom_endpoints + SET name = ?, base_url = ?, api_key = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nextName, nextUrl, nextKey, req.params.id) + + // Drop stale cached instances/catalog, then re-discover + handleEndpointRemoved(req.params.id) + const updated = getCustomEndpoint(req.params.id) + try { + await refreshEndpointCatalog(updated) + } catch { + // Discovery hiccup — user can refresh later + } + + res.json(toClientEndpoint(updated)) +}) + +/** + * POST /api/endpoints/:id/refresh-models + * Re-discover the model list for one endpoint. + */ +router.post('/:id/refresh-models', async (req, res) => { + const endpoint = getCustomEndpoint(req.params.id) + if (!endpoint) { + return res.status(404).json({ message: 'Endpoint not found' }) + } + try { + await refreshEndpointCatalog(endpoint) + res.json(toClientEndpoint(getCustomEndpoint(endpoint.id))) + } catch (err) { + res.status(400).json({ message: err.message || 'Could not reach the endpoint' }) + } +}) + +/** + * DELETE /api/endpoints/:id + * Remove a custom endpoint, its discovered catalog, and cached instances. + */ +router.delete('/:id', (req, res) => { + const existing = getCustomEndpoint(req.params.id) + if (!existing) { + return res.status(404).json({ message: 'Endpoint not found' }) + } + db.prepare('DELETE FROM custom_endpoints WHERE id = ?').run(req.params.id) + handleEndpointRemoved(req.params.id) + res.json({ success: true }) +}) + +export default router diff --git a/server/utils/mask.js b/server/utils/mask.js new file mode 100644 index 0000000..86877dc --- /dev/null +++ b/server/utils/mask.js @@ -0,0 +1,34 @@ +/** + * @fileoverview Credential masking helpers. + * + * API keys must never travel back to the browser in plain text. + * Every GET response that includes a stored credential runs it + * through maskSecret() first. + */ + +/** + * Mask a secret value for display. + * Returns the mask characters plus the last 4 characters of the secret. + * A secret with 4 or fewer characters returns mask characters only, + * so the full value never leaks. + * + * @param {string|null|undefined} value - The secret to mask + * @returns {string} The masked value (e.g. '••••1234') or '' when empty + */ +export function maskSecret(value) { + if (!value || typeof value !== 'string') return '' + if (value.length <= 4) return '••••' + return `••••${value.slice(-4)}` +} + +/** + * Check if a value looks like an already-masked secret. + * The settings UI sends masked values back on unrelated form saves. + * The server must not overwrite a real key with its own mask. + * + * @param {string} value + * @returns {boolean} + */ +export function isMaskedValue(value) { + return typeof value === 'string' && value.startsWith('••••') +} diff --git a/src/__tests__/challenger_empirical_stress_phase2.test.jsx b/src/__tests__/challenger_empirical_stress_phase2.test.jsx index 6c2cff7..20e321b 100644 --- a/src/__tests__/challenger_empirical_stress_phase2.test.jsx +++ b/src/__tests__/challenger_empirical_stress_phase2.test.jsx @@ -52,6 +52,15 @@ vi.mock('../utils/api', () => { update: vi.fn(), testApiKey: vi.fn(), getAvailableModels: vi.fn(() => Promise.resolve({ groups: [], providers: [] })), + refreshModels: vi.fn(() => Promise.resolve({ groups: [], providers: [], errors: {} })), + }, + endpointsApi: { + list: vi.fn(() => Promise.resolve([])), + create: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve({})), + delete: vi.fn(() => Promise.resolve({ success: true })), + test: vi.fn(() => Promise.resolve({ ok: true, modelCount: 0 })), + refreshModels: vi.fn(() => Promise.resolve({ models: [] })), }, decksApi: { list: vi.fn(() => Promise.resolve(mockDecks)), diff --git a/src/__tests__/chat_integration.test.jsx b/src/__tests__/chat_integration.test.jsx index 7cb1c0a..e83d2df 100644 --- a/src/__tests__/chat_integration.test.jsx +++ b/src/__tests__/chat_integration.test.jsx @@ -62,6 +62,15 @@ vi.mock('../utils/api', () => { update: vi.fn(() => Promise.resolve({ success: true })), testApiKey: vi.fn(() => Promise.resolve({ valid: true })), getAvailableModels: vi.fn(() => Promise.resolve({ groups: [], providers: [] })), + refreshModels: vi.fn(() => Promise.resolve({ groups: [], providers: [], errors: {} })), + }, + endpointsApi: { + list: vi.fn(() => Promise.resolve([])), + create: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve({})), + delete: vi.fn(() => Promise.resolve({ success: true })), + test: vi.fn(() => Promise.resolve({ ok: true, modelCount: 0 })), + refreshModels: vi.fn(() => Promise.resolve({ models: [] })), }, decksApi: { list: vi.fn(() => Promise.resolve(mockDecks)), diff --git a/src/__tests__/guide_settings.test.jsx b/src/__tests__/guide_settings.test.jsx index 62119e5..87bb394 100644 --- a/src/__tests__/guide_settings.test.jsx +++ b/src/__tests__/guide_settings.test.jsx @@ -18,6 +18,15 @@ vi.mock('../utils/api', () => ({ update: vi.fn(), testApiKey: vi.fn(), getAvailableModels: vi.fn(() => Promise.resolve({ groups: [], providers: [] })), + refreshModels: vi.fn(() => Promise.resolve({ groups: [], providers: [], errors: {} })), + }, + endpointsApi: { + list: vi.fn(() => Promise.resolve([])), + create: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve({})), + delete: vi.fn(() => Promise.resolve({ success: true })), + test: vi.fn(() => Promise.resolve({ ok: true, modelCount: 0 })), + refreshModels: vi.fn(() => Promise.resolve({ models: [] })), }, decksApi: { list: vi.fn(() => Promise.resolve([])), diff --git a/src/__tests__/model_management.test.jsx b/src/__tests__/model_management.test.jsx new file mode 100644 index 0000000..4a53603 --- /dev/null +++ b/src/__tests__/model_management.test.jsx @@ -0,0 +1,318 @@ +/** + * Multi-provider model management tests: + * - safe active-model fallback when providers/endpoints disappear + * - settings page provider cards (Gemini, Claude, OpenAI) + * - custom endpoint (BYOM) add/test/delete flows with presets + * - manual "Refresh Models" catalog re-sync + * - grouped model pickers (settings + sidebar) incl. custom endpoint groups + */ +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import SettingsPage from '../pages/SettingsPage' +import Sidebar from '../components/layout/Sidebar' +import useAppStore from '../stores/appStore' +import { configApi, endpointsApi } from '../utils/api' + +vi.mock('../utils/api', () => ({ + configApi: { + get: vi.fn(() => Promise.resolve({ api_keys_configured: { gemini: false, claude: false, openai: false } })), + update: vi.fn(() => Promise.resolve({ success: true })), + testApiKey: vi.fn(() => Promise.resolve({ valid: true, modelsDiscovered: 3 })), + getAvailableModels: vi.fn(() => Promise.resolve({ groups: [], providers: [] })), + refreshModels: vi.fn(() => Promise.resolve({ groups: [], providers: [], errors: {}, refreshed: [] })), + }, + endpointsApi: { + list: vi.fn(() => Promise.resolve([])), + create: vi.fn(() => Promise.resolve({ id: 'ep-1', name: 'Ollama', models: [] })), + update: vi.fn(() => Promise.resolve({})), + delete: vi.fn(() => Promise.resolve({ success: true })), + test: vi.fn(() => Promise.resolve({ ok: true, modelCount: 2 })), + refreshModels: vi.fn(() => Promise.resolve({ models: [] })), + }, + systemApi: { + stats: vi.fn(() => Promise.resolve({ guideCount: 0, flashcardsCount: 0, boardsCount: 0, cachedStartersCount: 0 })), + clearCache: vi.fn(() => Promise.resolve({ success: true })), + exportDbUrl: vi.fn(() => '#'), + }, + profileApi: { + get: vi.fn(() => Promise.resolve({ profileText: '' })), + update: vi.fn(() => Promise.resolve({ success: true })), + }, + guideContentApi: { + exportUrl: vi.fn(() => '#'), + }, +})) + +const GEMINI_GROUP = { + provider: { id: 'gemini', name: 'Google Gemini', color: '#4285F4' }, + models: [ + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: 'Fast', family: 'Flash' }, + { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Smart', family: 'Pro' }, + ], +} + +const CUSTOM_GROUP = { + provider: { id: 'custom:ep-1', name: 'Homelab Ollama', color: '#8B5CF6', isCustom: true }, + models: [ + { id: 'custom:ep-1:llama3.2:3b', name: 'Llama3.2 3b', description: 'Served by Homelab Ollama', family: null }, + ], +} + +beforeEach(() => { + useAppStore.setState({ + model: 'gemini-3.5-flash', + availableModels: [], + toasts: [], + sidebarCollapsed: false, + }) + // Re-establish default mock behavior (overrides from prior tests reset here) + configApi.get.mockResolvedValue({ api_keys_configured: { gemini: false, claude: false, openai: false } }) + configApi.getAvailableModels.mockResolvedValue({ groups: [], providers: [] }) + configApi.testApiKey.mockResolvedValue({ valid: true, modelsDiscovered: 3 }) + configApi.refreshModels.mockResolvedValue({ groups: [], providers: [], errors: {}, refreshed: [] }) + endpointsApi.list.mockResolvedValue([]) +}) + +describe('safe active-model fallback (appStore.applyModelGroups)', () => { + it('keeps the active model when it is still available', () => { + useAppStore.setState({ model: 'gemini-3.1-pro' }) + useAppStore.getState().applyModelGroups([GEMINI_GROUP]) + expect(useAppStore.getState().model).toBe('gemini-3.1-pro') + }) + + it('resets to the first available model when the active one disappears', () => { + useAppStore.setState({ model: 'claude-opus-4-8' }) + useAppStore.getState().applyModelGroups([GEMINI_GROUP]) + expect(useAppStore.getState().model).toBe('gemini-3.5-flash') + }) + + it('keeps the pointer untouched when no models are configured at all', () => { + useAppStore.setState({ model: 'claude-opus-4-8' }) + useAppStore.getState().applyModelGroups([]) + expect(useAppStore.getState().model).toBe('claude-opus-4-8') + }) + + it('falls back to a custom endpoint model when only an endpoint remains', () => { + useAppStore.setState({ model: 'gemini-3.5-flash' }) + useAppStore.getState().applyModelGroups([CUSTOM_GROUP]) + expect(useAppStore.getState().model).toBe('custom:ep-1:llama3.2:3b') + }) +}) + +describe('settings page — provider key management', () => { + it('renders cards for Gemini, Claude, and OpenAI with portal links', async () => { + render() + expect(screen.getByText('Google Gemini')).toBeInTheDocument() + expect(screen.getByText('Anthropic Claude')).toBeInTheDocument() + expect(screen.getByText('OpenAI')).toBeInTheDocument() + + expect(screen.getByRole('link', { name: 'Google AI Studio' })).toHaveAttribute( + 'href', 'https://aistudio.google.com/apikey' + ) + expect(screen.getByRole('link', { name: 'Anthropic Console' })).toHaveAttribute( + 'href', 'https://console.anthropic.com/settings/keys' + ) + expect(screen.getByRole('link', { name: 'OpenAI Platform' })).toHaveAttribute( + 'href', 'https://platform.openai.com/api-keys' + ) + await waitFor(() => expect(configApi.get).toHaveBeenCalled()) + }) + + it('verifies a key before saving and reports discovered models', async () => { + const { container } = render() + const input = container.querySelector('#openai-api-key-input') + fireEvent.change(input, { target: { value: 'sk-valid' } }) + + const section = input.closest('.settings-field').parentElement + fireEvent.click(within(section).getByText('Save & Verify')) + + await waitFor(() => { + expect(configApi.testApiKey).toHaveBeenCalledWith('sk-valid', 'openai') + }) + await waitFor(() => { + expect(within(section).getByText('Connected — AI features enabled')).toBeInTheDocument() + }) + }) + + it('shows a connection error and does not mark connected for invalid keys', async () => { + configApi.testApiKey.mockRejectedValueOnce(new Error('Invalid API key')) + const { container } = render() + const input = container.querySelector('#gemini-api-key-input') + fireEvent.change(input, { target: { value: 'bad-key' } }) + + const section = input.closest('.settings-field').parentElement + fireEvent.click(within(section).getByText('Save & Verify')) + + await waitFor(() => { + expect( + within(section).getByText('Connection error — check your key and try again') + ).toBeInTheDocument() + }) + expect(within(section).queryByText('Connected — AI features enabled')).not.toBeInTheDocument() + }) +}) + +describe('settings page — custom endpoints (BYOM)', () => { + it('adds an endpoint using the Ollama preset', async () => { + const { container } = render() + const section = container.querySelector('#custom-endpoints-section') + + fireEvent.click(within(section).getByText('Add Custom Endpoint')) + fireEvent.click(within(section).getByText('Ollama')) + + const urlInput = container.querySelector('#endpoint-url-input') + expect(urlInput.value).toBe('http://localhost:11434/v1') + + fireEvent.click(within(section).getByText('Save & Verify')) + await waitFor(() => { + expect(endpointsApi.create).toHaveBeenCalledWith({ + name: 'Ollama', + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + }) + }) + }) + + it('tests an endpoint without saving it', async () => { + const { container } = render() + const section = container.querySelector('#custom-endpoints-section') + + fireEvent.click(within(section).getByText('Add Custom Endpoint')) + fireEvent.change(container.querySelector('#endpoint-url-input'), { + target: { value: 'http://localhost:1234/v1' }, + }) + fireEvent.click(within(section).getByText('Test Connection')) + + await waitFor(() => { + expect(endpointsApi.test).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:1234/v1', + apiKey: '', + endpointId: undefined, + }) + }) + expect(endpointsApi.create).not.toHaveBeenCalled() + }) + + it('lists saved endpoints with masked keys and model counts', async () => { + endpointsApi.list.mockResolvedValueOnce([ + { + id: 'ep-1', + name: 'Homelab Ollama', + baseUrl: 'http://10.0.0.5:11434/v1', + apiKeyMasked: '••••abcd', + hasApiKey: true, + models: [{ id: 'custom:ep-1:llama3.2:3b' }, { id: 'custom:ep-1:qwen2.5' }], + }, + ]) + render() + + expect(await screen.findByText('Homelab Ollama')).toBeInTheDocument() + expect(screen.getByText(/••••abcd/)).toBeInTheDocument() + expect(screen.getByText('2 models available')).toBeInTheDocument() + // The raw key never appears anywhere + expect(screen.queryByText(/sk-/)).not.toBeInTheDocument() + }) + + it('deletes an endpoint after confirmation', async () => { + endpointsApi.list.mockResolvedValue([ + { + id: 'ep-1', + name: 'Homelab Ollama', + baseUrl: 'http://10.0.0.5:11434/v1', + apiKeyMasked: '', + hasApiKey: false, + models: [], + }, + ]) + render() + await screen.findByText('Homelab Ollama') + + fireEvent.click(screen.getByLabelText('Remove Homelab Ollama')) + fireEvent.click(await screen.findByText('Remove')) + await waitFor(() => { + expect(endpointsApi.delete).toHaveBeenCalledWith('ep-1') + }) + }) +}) + +describe('settings page — model catalog', () => { + it('re-syncs catalogs via the Refresh Models button', async () => { + configApi.refreshModels.mockResolvedValueOnce({ + groups: [GEMINI_GROUP], + providers: [], + errors: {}, + refreshed: ['gemini'], + }) + const { container } = render() + fireEvent.click(container.querySelector('#refresh-models-btn')) + + await waitFor(() => expect(configApi.refreshModels).toHaveBeenCalled()) + await waitFor(() => { + expect(useAppStore.getState().availableModels).toEqual([GEMINI_GROUP]) + }) + }) + + it('groups models under provider headers with family labels', async () => { + configApi.getAvailableModels.mockResolvedValue({ groups: [GEMINI_GROUP, CUSTOM_GROUP], providers: [] }) + render() + + expect(await screen.findByText('Gemini 3.5 Flash')).toBeInTheDocument() + expect(screen.getByText('Flash')).toBeInTheDocument() + expect(screen.getByText('Llama3.2 3b')).toBeInTheDocument() + expect(screen.getAllByText('Homelab Ollama').length).toBeGreaterThan(0) + }) + + it('shows an encouraging empty state when nothing is configured', () => { + render() + expect( + screen.getByText('No models available yet. Add an API key or a custom endpoint above to unlock AI features.') + ).toBeInTheDocument() + }) +}) + +describe('sidebar — grouped model picker', () => { + it('groups models by provider including custom endpoints', () => { + configApi.getAvailableModels.mockResolvedValue({ groups: [GEMINI_GROUP, CUSTOM_GROUP], providers: [] }) + useAppStore.setState({ availableModels: [GEMINI_GROUP, CUSTOM_GROUP] }) + const { container } = render( + + + + ) + + const select = container.querySelector('#sidebar-model-select') + const groups = Array.from(select.querySelectorAll('optgroup')).map((g) => g.label) + expect(groups).toEqual(['Google Gemini', 'Homelab Ollama']) + + const customOption = select.querySelector('option[value="custom:ep-1:llama3.2:3b"]') + expect(customOption).not.toBe(null) + }) + + it('switches the global model from the sidebar', () => { + configApi.getAvailableModels.mockResolvedValue({ groups: [GEMINI_GROUP, CUSTOM_GROUP], providers: [] }) + useAppStore.setState({ availableModels: [GEMINI_GROUP, CUSTOM_GROUP] }) + const { container } = render( + + + + ) + fireEvent.change(container.querySelector('#sidebar-model-select'), { + target: { value: 'custom:ep-1:llama3.2:3b' }, + }) + expect(useAppStore.getState().model).toBe('custom:ep-1:llama3.2:3b') + }) + + it('links to Settings when no models are configured', () => { + useAppStore.setState({ availableModels: [] }) + const { container } = render( + + + + ) + expect(container.querySelector('#sidebar-model-select')).toBe(null) + const emptyState = container.querySelector('#sidebar-model-empty-state') + expect(emptyState).not.toBe(null) + expect(emptyState.getAttribute('href')).toBe('/settings') + }) +}) diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 9575bc6..5a807fd 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -94,33 +94,53 @@ export default function Sidebar() { {!collapsed && (
Model
- + {availableModels.some((g) => g.models.length > 0) ? ( + + ) : ( + + Add a provider in Settings → + + )}
)} diff --git a/src/index.css b/src/index.css index 4b919c5..66ad6f8 100644 --- a/src/index.css +++ b/src/index.css @@ -1435,6 +1435,10 @@ textarea.input { color: var(--color-text-tertiary); } +.api-key-status.error { + color: var(--color-error); +} + .api-key-status-dot { width: 6px; height: 6px; diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 01b8313..6f09399 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react' -import { Eye, EyeOff, Download, Upload, Trash2, Sun, Moon } from 'lucide-react' +import { Eye, EyeOff, Download, Upload, Trash2, Sun, Moon, RefreshCw, Plus, Server } from 'lucide-react' import useAppStore from '../stores/appStore' -import { configApi, systemApi, profileApi, guideContentApi } from '../utils/api' +import { configApi, systemApi, profileApi, guideContentApi, endpointsApi } from '../utils/api' import Modal from '../components/shared/Modal' /** @@ -30,12 +30,33 @@ const DEFAULT_PROVIDER_DEFS = [ keyHelpUrl: 'https://console.anthropic.com/settings/keys', keyHelpLabel: 'Anthropic Console', }, + { + id: 'openai', + name: 'OpenAI', + shortName: 'OpenAI', + configKey: 'openai_api_key', + color: '#10A37F', + keyPlaceholder: 'sk-...', + keyHelpUrl: 'https://platform.openai.com/api-keys', + keyHelpLabel: 'OpenAI Platform', + }, +] + +/** + * Quick-fill presets for popular OpenAI-compatible engines. + */ +const ENDPOINT_PRESETS = [ + { label: 'Ollama', name: 'Ollama', baseUrl: 'http://localhost:11434/v1' }, + { label: 'LM Studio', name: 'LM Studio', baseUrl: 'http://localhost:1234/v1' }, + { label: 'vLLM', name: 'vLLM', baseUrl: 'http://localhost:8000/v1' }, + { label: 'OpenRouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' }, + { label: 'Groq', name: 'Groq', baseUrl: 'https://api.groq.com/openai/v1' }, ] /** * Reusable component for managing a single provider's API key. */ -function ProviderKeySection({ providerId, providerDef, keyStatus, onKeyStatusChange }) { +function ProviderKeySection({ providerId, providerDef, keyStatus, maskedKey, onKeyStatusChange }) { const addToast = useAppStore((s) => s.addToast) const [apiKey, setApiKey] = useState('') const [showKey, setShowKey] = useState(false) @@ -50,13 +71,16 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, onKeyStatusCha if (result.valid) { onKeyStatusChange(providerId, 'connected') setApiKey('') - addToast({ type: 'success', message: `${providerDef.shortName} API key saved and verified` }) + const discovered = result.modelsDiscovered + ? ` — ${result.modelsDiscovered} models discovered` + : '' + addToast({ type: 'success', message: `${providerDef.shortName} API key verified${discovered}` }) } else { - onKeyStatusChange(providerId, 'disconnected') + onKeyStatusChange(providerId, 'error') addToast({ type: 'error', message: `Invalid ${providerDef.shortName} API key` }) } } catch (err) { - onKeyStatusChange(providerId, 'disconnected') + onKeyStatusChange(providerId, 'error') addToast({ type: 'error', message: err.message || `Failed to verify ${providerDef.shortName} API key` }) } finally { setIsTesting(false) @@ -97,7 +121,7 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, onKeyStatusCha id={`${providerId}-api-key-input`} className="input" type={showKey ? 'text' : 'password'} - placeholder={providerDef.keyPlaceholder} + placeholder={keyStatus === 'connected' && maskedKey ? maskedKey : providerDef.keyPlaceholder} value={apiKey} onChange={(e) => setApiKey(e.target.value)} /> @@ -120,7 +144,9 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, onKeyStatusCha {keyStatus === 'connected' ? 'Connected — AI features enabled' - : 'Not configured'} + : keyStatus === 'error' + ? 'Connection error — check your key and try again' + : 'Not configured'}

Get your API key from{' '} @@ -181,6 +207,277 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, onKeyStatusCha ) } +/** + * "Bring Your Own Model" — manage custom OpenAI-compatible endpoints + * (Ollama, LM Studio, vLLM, OpenRouter, Groq, ...). + */ +function CustomEndpointsSection() { + const addToast = useAppStore((s) => s.addToast) + const fetchAvailableModels = useAppStore((s) => s.fetchAvailableModels) + const [endpoints, setEndpoints] = useState([]) + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState({ name: '', baseUrl: '', apiKey: '' }) + const [isSaving, setIsSaving] = useState(false) + const [testingId, setTestingId] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const loadEndpoints = useCallback(() => { + endpointsApi.list().then((rows) => setEndpoints(rows || [])).catch(() => {}) + }, []) + + useEffect(() => { + loadEndpoints() + }, [loadEndpoints]) + + const openAddForm = () => { + setEditingId(null) + setForm({ name: '', baseUrl: '', apiKey: '' }) + setShowForm(true) + } + + const openEditForm = (ep) => { + setEditingId(ep.id) + setForm({ name: ep.name, baseUrl: ep.baseUrl, apiKey: '' }) + setShowForm(true) + } + + const applyPreset = (preset) => { + setForm((f) => ({ ...f, name: f.name || preset.name, baseUrl: preset.baseUrl })) + } + + const handleTestForm = async () => { + setTestingId('form') + try { + const result = await endpointsApi.test({ + baseUrl: form.baseUrl, + apiKey: form.apiKey, + endpointId: editingId || undefined, + }) + addToast({ type: 'success', message: `Endpoint reachable — ${result.modelCount} models found` }) + } catch (err) { + addToast({ type: 'error', message: err.message || 'Could not reach the endpoint' }) + } finally { + setTestingId(null) + } + } + + const handleSave = async () => { + setIsSaving(true) + try { + const payload = { name: form.name, baseUrl: form.baseUrl, apiKey: form.apiKey } + if (editingId) { + await endpointsApi.update(editingId, payload) + addToast({ type: 'success', message: `Endpoint "${form.name}" updated` }) + } else { + const created = await endpointsApi.create(payload) + addToast({ + type: 'success', + message: `Endpoint "${created.name}" connected — ${created.models?.length ?? 0} models discovered`, + }) + } + setShowForm(false) + setEditingId(null) + loadEndpoints() + fetchAvailableModels() + } catch (err) { + addToast({ type: 'error', message: err.message || 'Failed to save endpoint' }) + } finally { + setIsSaving(false) + } + } + + const handleTestSaved = async (ep) => { + setTestingId(ep.id) + try { + const refreshed = await endpointsApi.refreshModels(ep.id) + addToast({ type: 'success', message: `"${ep.name}" reachable — ${refreshed.models?.length ?? 0} models` }) + loadEndpoints() + fetchAvailableModels() + } catch (err) { + addToast({ type: 'error', message: err.message || `Could not reach "${ep.name}"` }) + } finally { + setTestingId(null) + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + try { + await endpointsApi.delete(deleteTarget.id) + addToast({ type: 'info', message: `Endpoint "${deleteTarget.name}" removed` }) + setDeleteTarget(null) + loadEndpoints() + fetchAvailableModels() + } catch (err) { + addToast({ type: 'error', message: err.message || 'Failed to remove endpoint' }) + } + } + + return ( +

+

Custom Endpoints — Bring Your Own Model

+

+ Connect any OpenAI-compatible server (Ollama, LM Studio, vLLM, OpenRouter, Groq). + Discovered models join the global model picker under the endpoint's name. +

+ + {endpoints.length === 0 && !showForm && ( +

+ No custom endpoints yet. Add one to run models locally and privately. +

+ )} + + {endpoints.map((ep) => ( +
+ +
+
+ {ep.name} +
+
+ {ep.baseUrl} + {ep.hasApiKey ? ` · key ${ep.apiKeyMasked}` : ''} +
+
0 ? 'connected' : 'disconnected'}`}> + + {ep.models.length > 0 ? `${ep.models.length} models available` : 'No models discovered yet'} +
+
+ + + +
+ ))} + + {showForm ? ( +
+
+ {ENDPOINT_PRESETS.map((preset) => ( + + ))} +
+
+ setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, baseUrl: e.target.value }))} + /> + setForm((f) => ({ ...f, apiKey: e.target.value }))} + /> +
+
+ + + +
+
+ ) : ( + + )} + + setDeleteTarget(null)} + title="Remove Custom Endpoint" + footer={ + <> + + + + } + > +

+ Remove "{deleteTarget?.name}"? Its models disappear from the model picker. +

+
+
+ ) +} + export default function SettingsPage() { const { addToast, theme, toggleTheme, model, setModel, fetchAvailableModels, availableModels } = useAppStore() const isMac = typeof window !== 'undefined' && navigator.userAgent.includes('Mac') @@ -191,6 +488,9 @@ export default function SettingsPage() { // Per-provider key status — initialized dynamically from fetched providers const [keyStatuses, setKeyStatuses] = useState({}) const [providerDefs, setProviderDefs] = useState(DEFAULT_PROVIDER_DEFS) + // Masked stored keys (e.g. '••••1234') per provider, from the config API + const [maskedKeys, setMaskedKeys] = useState({}) + const [isRefreshingModels, setIsRefreshingModels] = useState(false) const handleKeyStatusChange = useCallback((providerId, status) => { setKeyStatuses((prev) => ({ ...prev, [providerId]: status })) @@ -222,6 +522,12 @@ export default function SettingsPage() { } else if (config.api_key_configured) { statuses.gemini = 'connected' } + // Collect masked key hints (config values arrive pre-masked) + const masked = {} + for (const def of DEFAULT_PROVIDER_DEFS) { + if (config[def.configKey]) masked[def.id] = config[def.configKey] + } + setMaskedKeys(masked) setKeyStatuses(statuses) const configuredMap = {} for (const [id, s] of Object.entries(statuses)) { @@ -248,6 +554,27 @@ export default function SettingsPage() { } } + const handleRefreshModels = async () => { + setIsRefreshingModels(true) + try { + const data = await useAppStore.getState().refreshModels() + const errorEntries = Object.entries(data.errors || {}) + if (errorEntries.length > 0) { + addToast({ + type: 'error', + message: `Some catalogs failed to refresh: ${errorEntries.map(([id, msg]) => `${id}: ${msg}`).join('; ')}`, + }) + } else { + const total = (data.groups || []).reduce((sum, g) => sum + g.models.length, 0) + addToast({ type: 'success', message: `Model catalog refreshed — ${total} models available` }) + } + } catch (err) { + addToast({ type: 'error', message: err.message || 'Failed to refresh models' }) + } finally { + setIsRefreshingModels(false) + } + } + const handleClearCache = async () => { try { await systemApi.clearCache() @@ -298,6 +625,7 @@ export default function SettingsPage() { providerId={def.id} providerDef={def} keyStatus={keyStatuses[def.id] || 'disconnected'} + maskedKey={maskedKeys[def.id] || ''} onKeyStatusChange={handleKeyStatusChange} /> ))} @@ -305,6 +633,11 @@ export default function SettingsPage() {
+ {/* Custom Endpoints (BYOM) */} + + +
+ {/* AI Shadow Memory */}

AI Shadow Memory

@@ -334,14 +667,27 @@ export default function SettingsPage() { {/* Model Selection */}
-

AI Model

+
+

AI Model

+ +

- Choose which AI model to use for chat and content generation. Available models depend on your configured API keys. + Choose which AI model to use for chat and content generation. Models are discovered live from your + configured providers and endpoints — Refresh Models re-syncs the catalog at any time.

{allModels.length === 0 ? (

- No models available. Configure at least one API key above. + No models available yet. Add an API key or a custom endpoint above to unlock AI features.

) : (
@@ -397,7 +743,26 @@ export default function SettingsPage() { style={{ accentColor: group.provider.color }} />
-
{m.name}
+
+ {m.name} + {m.family && ( + + {m.family} + + )} +
{m.description}
diff --git a/src/stores/appStore.js b/src/stores/appStore.js index 8f82216..b2ec7e9 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -38,7 +38,7 @@ applyTheme(initialTheme) * Global application store using Zustand. * Manages sidebar state, active views, theme, and shared UI state. */ -const useAppStore = create((set) => ({ +const useAppStore = create((set, get) => ({ // Theme theme: initialTheme, toggleTheme: () => @@ -90,12 +90,33 @@ const useAppStore = create((set) => ({ fetchAvailableModels: async () => { try { const data = await configApi.getAvailableModels() - set({ availableModels: data.groups || [] }) + get().applyModelGroups(data.groups || []) } catch { // Silently fail — models will just show as empty } }, + // Trigger a live re-sync with all providers/endpoints, then apply the result + refreshModels: async () => { + const data = await configApi.refreshModels() + get().applyModelGroups(data.groups || []) + return data + }, + + /** + * Apply a fresh set of model groups. + * Safe fallback: when the active model disappears (key removed, + * endpoint deleted, model no longer served), the selection resets to + * the first available model so AI features keep working. + */ + applyModelGroups: (groups) => { + set({ availableModels: groups }) + const allIds = groups.flatMap((g) => g.models.map((m) => m.id)) + if (allIds.length > 0 && !allIds.includes(get().model)) { + get().setModel(allIds[0]) + } + }, + // Toast notifications toasts: [], addToast: (toast) => diff --git a/src/utils/api.js b/src/utils/api.js index 02562df..b47a779 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -66,10 +66,28 @@ async function request(path, options = {}) { export const configApi = { get: () => request('/config'), update: (data) => request('/config', { method: 'PUT', body: data }), - /** Test an API key for a specific provider. provider: 'gemini' | 'claude' */ + /** Test an API key for a specific provider. provider: 'gemini' | 'claude' | 'openai' */ testApiKey: (key, provider = 'gemini') => request('/config/test-key', { method: 'POST', body: { key, provider } }), /** Get all available models based on configured API keys, grouped by provider */ getAvailableModels: () => request('/config/available-models'), + /** Re-sync model catalogs with every configured provider and custom endpoint */ + refreshModels: () => request('/config/refresh-models', { method: 'POST' }), +} + +/* ---- Custom Endpoints (BYOM) ---- */ +export const endpointsApi = { + /** List registered custom endpoints (keys masked, cached models attached) */ + list: () => request('/endpoints'), + /** Register a custom endpoint. data: { name, baseUrl, apiKey? } */ + create: (data) => request('/endpoints', { method: 'POST', body: data }), + /** Edit a custom endpoint. Masked/omitted apiKey keeps the stored key. */ + update: (id, data) => request(`/endpoints/${id}`, { method: 'PUT', body: data }), + /** Remove a custom endpoint and its discovered models */ + delete: (id) => request(`/endpoints/${id}`, { method: 'DELETE' }), + /** Ping an endpoint without saving. data: { baseUrl, apiKey?, endpointId? } */ + test: (data) => request('/endpoints/test', { method: 'POST', body: data }), + /** Re-discover the model list for one endpoint */ + refreshModels: (id) => request(`/endpoints/${id}/refresh-models`, { method: 'POST' }), } /* ---- Decks ---- */ From 3f6c4c6177ba0371d08fabb74be02e493e1902c7 Mon Sep 17 00:00:00 2001 From: Arvind Arikatla Date: Sun, 16 Aug 2026 19:41:21 -0700 Subject: [PATCH 2/2] feat: latest-per-family model filter and redesigned model picker UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model filtering (server): - New "latest generation per family" heuristic on top of the existing guardrails: classify models into families (Gemini Pro/Flash/Flash-Lite/ Gemma; Claude Fable/Opus/Sonnet/Haiku; OpenAI Flagship/Balanced/Fast/ Mini/Nano/Reasoning incl. GPT-5.6 sol/terra/luna tier names), extract generation numbers, and keep only the newest generation of each family, preferring stable releases over previews and collapsing suffix variants - Strip specialized non-chat systems: image generation (Nano Banana), music (Lyria), robotics, deep-research agents, computer-use models - OpenAI static fallback updated to the GPT-5.6 family (Sol/Terra/Luna) - Verified live: Gemini 27→4, Claude 10→4, OpenAI shows current lineup Model selection UX (frontend, mobile-first): - New ModelPicker component replaces the sidebar setModel(e.target.value)} - id="sidebar-model-select" - style={{ - width: '100%', - background: 'var(--color-surface)', - color: 'var(--color-text-secondary)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - padding: '4px 8px', - fontSize: '11px', - outline: 'none', - cursor: 'pointer' - }} - > - {availableModels - .filter((group) => group.models.length > 0) - .map((group) => ( - - {group.models.map((m) => ( - - ))} - - ))} - - ) : ( - - Add a provider in Settings → - - )} +
)} diff --git a/src/components/shared/ModelPicker.jsx b/src/components/shared/ModelPicker.jsx new file mode 100644 index 0000000..48de4cc --- /dev/null +++ b/src/components/shared/ModelPicker.jsx @@ -0,0 +1,243 @@ +/** + * @fileoverview ModelPicker — the unified model selection surface. + * + * One component, three trigger shapes: + * - variant="sidebar" → full-width chip in the sidebar footer + * - variant="header" → compact pill in the mobile header + * - variant="settings" → "Change model" button on the settings page + * + * The trigger opens the picker: a bottom sheet on mobile, a centered + * command-palette dialog on desktop. Inside: search, models grouped + * under provider headers with brand accents and family chips, a + * catalog refresh action, and a link to provider management. + */ +import { useState, useEffect, useMemo, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate } from 'react-router-dom' +import { Search, Check, RefreshCw, ChevronsUpDown, Settings2, Boxes } from 'lucide-react' +import useAppStore from '../../stores/appStore' + +/** Flatten model groups and find the entry for an ID. */ +function findModel(groups, id) { + for (const group of groups) { + const m = group.models.find((m) => m.id === id) + if (m) return { ...m, providerColor: group.provider.color, providerName: group.provider.name } + } + return null +} + +export default function ModelPicker({ variant = 'sidebar' }) { + const model = useAppStore((s) => s.model) + const setModel = useAppStore((s) => s.setModel) + const availableModels = useAppStore((s) => s.availableModels) + const fetchAvailableModels = useAppStore((s) => s.fetchAvailableModels) + const addToast = useAppStore((s) => s.addToast) + const navigate = useNavigate() + + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [isRefreshing, setIsRefreshing] = useState(false) + const searchRef = useRef(null) + + const openPicker = () => { + setQuery('') + setOpen(true) + } + + // Re-sync the cached catalog whenever the picker opens + useEffect(() => { + if (open) { + fetchAvailableModels() + // Focus search after the sheet animates in + const t = setTimeout(() => searchRef.current?.focus(), 60) + return () => clearTimeout(t) + } + }, [open, fetchAvailableModels]) + + // Close on Escape; lock body scroll while open + useEffect(() => { + if (!open) return + const onKey = (e) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('keydown', onKey) + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prevOverflow + } + }, [open]) + + const activeModel = useMemo( + () => findModel(availableModels, model), + [availableModels, model] + ) + + const filteredGroups = useMemo(() => { + const q = query.trim().toLowerCase() + return availableModels + .map((group) => ({ + ...group, + models: group.models.filter((m) => { + if (!q) return true + return [m.name, m.id, m.family, group.provider.name] + .filter(Boolean) + .some((s) => s.toLowerCase().includes(q)) + }), + })) + .filter((group) => group.models.length > 0) + }, [availableModels, query]) + + const hasAnyModels = availableModels.some((g) => g.models.length > 0) + + const handleSelect = (m) => { + if (m.id !== model) { + setModel(m.id) + addToast({ type: 'info', message: `Model switched to ${m.name}` }) + } + setOpen(false) + } + + const handleRefresh = async () => { + setIsRefreshing(true) + try { + const data = await useAppStore.getState().refreshModels() + const errors = Object.entries(data.errors || {}) + if (errors.length > 0) { + addToast({ type: 'error', message: `Some catalogs failed to refresh: ${errors.map(([id]) => id).join(', ')}` }) + } + } catch (err) { + addToast({ type: 'error', message: err.message || 'Failed to refresh models' }) + } finally { + setIsRefreshing(false) + } + } + + const goToSettings = () => { + setOpen(false) + navigate('/settings') + } + + // ── Trigger ────────────────────────────────────────────── + const triggerLabel = activeModel ? activeModel.name : model + const triggerColor = activeModel ? activeModel.providerColor : 'var(--color-text-tertiary)' + + const trigger = + variant === 'settings' ? ( + + ) : ( + + ) + + // ── Sheet ──────────────────────────────────────────────── + const sheet = open + ? createPortal( +
setOpen(false)}> +
e.stopPropagation()} + > + +
, + document.body + ) + : null + + return ( + <> + {trigger} + {sheet} + + ) +} diff --git a/src/index.css b/src/index.css index 66ad6f8..c41f51c 100644 --- a/src/index.css +++ b/src/index.css @@ -1439,6 +1439,414 @@ textarea.input { color: var(--color-error); } +/* ---------- Status Pills ---------- */ +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: var(--radius-full); + white-space: nowrap; +} + +.status-pill::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.status-pill.connected { + color: var(--color-success); + background: var(--color-success-subtle); +} + +.status-pill.disconnected { + color: var(--color-text-tertiary); + background: var(--color-bg-tertiary); +} + +.status-pill.error { + color: var(--color-error); + background: var(--color-error-subtle); +} + +/* ---------- Provider Cards (Settings) ---------- */ +.provider-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-top: var(--space-3); + transition: border-color var(--duration-fast); +} + +.provider-card:hover { + border-color: var(--color-border-strong); +} + +.provider-card-header { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.provider-card-name { + flex: 1; + min-width: 0; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-primary); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-card-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ---------- Active Model Card (Settings) ---------- */ +.active-model-card { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-top: var(--space-3); +} + +.active-model-card-info { + flex: 1; + min-width: 180px; +} + +.active-model-card-name { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-md); + font-weight: 600; + color: var(--color-text-primary); +} + +.active-model-card-desc { + font-size: var(--text-xs); + color: var(--color-text-secondary); + margin-top: 2px; +} + +/* ---------- Model Picker ---------- */ +.model-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + border-radius: var(--radius-md); + cursor: pointer; + font-family: inherit; + transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast); +} + +.model-trigger:hover, +.model-trigger:focus-visible { + background: var(--color-surface-hover); + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.model-trigger-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.model-trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--color-text-primary); +} + +.model-trigger-sidebar { + width: 100%; + padding: 8px 10px; + font-size: var(--text-sm); +} + +.model-trigger-sidebar .model-trigger-label { + flex: 1; + text-align: left; +} + +.model-trigger-header { + padding: 5px 10px; + font-size: 11px; + border-radius: var(--radius-full); + max-width: 45vw; +} + +.model-trigger-family { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-tertiary); + border: 1px solid var(--color-border); + padding: 0 7px; + border-radius: var(--radius-full); + line-height: 17px; + flex-shrink: 0; +} + +.model-picker-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: flex-end; + justify-content: center; + animation: model-picker-fade var(--duration-fast) ease-out; +} + +.model-picker-sheet { + display: flex; + flex-direction: column; + width: 100%; + max-height: min(78dvh, 600px); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-bottom: none; + border-radius: var(--radius-2xl) var(--radius-2xl) 0 0; + box-shadow: var(--shadow-xl); + padding-bottom: env(safe-area-inset-bottom); + animation: model-picker-sheet-up var(--duration-normal) var(--ease-default); +} + +.model-picker-handle { + width: 36px; + height: 4px; + border-radius: var(--radius-full); + background: var(--color-border-strong); + margin: 10px auto 0; + flex-shrink: 0; +} + +.model-picker-search { + display: flex; + align-items: center; + gap: 8px; + margin: var(--space-3) var(--space-4); + padding: 0 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + color: var(--color-text-tertiary); + flex-shrink: 0; +} + +.model-picker-search:focus-within { + border-color: var(--color-border-accent); +} + +.model-picker-search input { + flex: 1; + min-width: 0; + padding: 11px 0; + background: none; + border: none; + outline: none; + color: var(--color-text-primary); + font-size: var(--text-base); + font-family: inherit; +} + +.model-picker-search input::placeholder { + color: var(--color-text-tertiary); +} + +.model-picker-list { + flex: 1; + overflow-y: auto; + padding: 0 var(--space-2) var(--space-2); + overscroll-behavior: contain; +} + +.model-picker-group-label { + display: flex; + align-items: center; + gap: 7px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: var(--space-3) var(--space-2) var(--space-1); +} + +.model-picker-group-label .model-trigger-dot { + width: 6px; + height: 6px; +} + +.model-picker-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 8px 12px; + border: none; + background: none; + border-radius: var(--radius-md); + cursor: pointer; + color: var(--color-text-primary); + font-family: inherit; + font-size: var(--text-sm); + text-align: left; + transition: background var(--duration-fast); +} + +.model-picker-option:hover, +.model-picker-option:focus-visible { + background: var(--color-bg-hover); +} + +.model-picker-option.active { + background: var(--color-accent-subtle); +} + +.model-picker-option-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.model-picker-check { + color: var(--color-accent); + flex-shrink: 0; +} + +.model-picker-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--color-text-tertiary); + font-size: var(--text-sm); +} + +.model-picker-empty-title { + font-size: var(--text-md); + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.model-picker-empty .btn { + margin-top: var(--space-2); +} + +.model-picker-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +.model-picker-footer-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border: none; + background: none; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--color-text-secondary); + font-family: inherit; + font-size: var(--text-xs); + transition: color var(--duration-fast), background var(--duration-fast); +} + +.model-picker-footer-btn:hover:not(:disabled) { + color: var(--color-text-primary); + background: var(--color-bg-hover); +} + +.model-picker-footer-btn:disabled { + opacity: 0.6; + cursor: default; +} + +@keyframes model-picker-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes model-picker-sheet-up { + from { transform: translateY(28px); opacity: 0.5; } + to { transform: none; opacity: 1; } +} + +@keyframes model-picker-pop { + from { transform: scale(0.97) translateY(6px); opacity: 0; } + to { transform: none; opacity: 1; } +} + +/* Desktop: command-palette dialog instead of bottom sheet */ +@media (min-width: 768px) { + .model-picker-overlay { + align-items: flex-start; + padding: 12vh var(--space-6) var(--space-6); + } + + .model-picker-sheet { + max-width: 460px; + max-height: min(64vh, 540px); + border-bottom: 1px solid var(--color-border); + border-radius: var(--radius-xl); + animation: model-picker-pop var(--duration-normal) var(--ease-default); + } + + .model-picker-handle { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .model-picker-overlay, + .model-picker-sheet { + animation: none; + } +} + .api-key-status-dot { width: 6px; height: 6px; diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 6f09399..77316f9 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -3,6 +3,7 @@ import { Eye, EyeOff, Download, Upload, Trash2, Sun, Moon, RefreshCw, Plus, Serv import useAppStore from '../stores/appStore' import { configApi, systemApi, profileApi, guideContentApi, endpointsApi } from '../utils/api' import Modal from '../components/shared/Modal' +import ModelPicker from '../components/shared/ModelPicker' /** * Default fallback provider config (used before server metadata loads). @@ -99,20 +100,13 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, maskedKey, onK } return ( -
-
-
-

- {providerDef.name} -

+
+
+ +

{providerDef.name}

+ + {keyStatus === 'connected' ? 'Connected' : keyStatus === 'error' ? 'Error' : 'Not configured'} +
@@ -140,14 +134,12 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, maskedKey, onK {isTesting ? 'Verifying...' : 'Save & Verify'}
-
- - {keyStatus === 'connected' - ? 'Connected — AI features enabled' - : keyStatus === 'error' - ? 'Connection error — check your key and try again' - : 'Not configured'} -
+ {keyStatus === 'error' && ( +
+ + The key was rejected. Check it and try again. +
+ )}

Get your API key from{' '} {providerDef.keyHelpUrl ? ( @@ -165,7 +157,7 @@ function ProviderKeySection({ providerId, providerDef, keyStatus, maskedKey, onK

{keyStatus === 'connected' && ( -
+
+ +
- - -
))} @@ -479,7 +459,7 @@ function CustomEndpointsSection() { } export default function SettingsPage() { - const { addToast, theme, toggleTheme, model, setModel, fetchAvailableModels, availableModels } = useAppStore() + const { addToast, theme, toggleTheme, model, fetchAvailableModels, availableModels } = useAppStore() const isMac = typeof window !== 'undefined' && navigator.userAgent.includes('Mac') const [systemStats, setSystemStats] = useState(null) const [profileText, setProfileText] = useState('') @@ -593,6 +573,7 @@ export default function SettingsPage() { const allModels = availableModels.flatMap(group => group.models.map(m => ({ ...m, providerColor: group.provider.color, providerName: group.provider.name })) ) + const activeModelInfo = allModels.find((m) => m.id === model) // Determine active providers for About section const activeProviders = Object.entries(keyStatuses) @@ -681,8 +662,9 @@ export default function SettingsPage() {

- Choose which AI model to use for chat and content generation. Models are discovered live from your - configured providers and endpoints — Refresh Models re-syncs the catalog at any time. + One model powers every AI feature — chat, whiteboard reviews, Feynman feedback, and flashcard + generation. Catalogs sync with each provider's latest releases; only the newest generation of + each family is shown.

{allModels.length === 0 ? ( @@ -690,85 +672,34 @@ export default function SettingsPage() { No models available yet. Add an API key or a custom endpoint above to unlock AI features.

) : ( -
- {/* Group by provider */} - {availableModels.map((group) => ( -
-
- - {group.provider.name} -
- {group.models.map((m) => ( -
)}