diff --git a/.githooks/audit-grace.cjs b/.githooks/audit-grace.cjs new file mode 100755 index 000000000..73db7a6c7 --- /dev/null +++ b/.githooks/audit-grace.cjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +"use strict"; + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +const GRACE_HOURS = 24; +const CACHE_TTL_DAYS = 7; +const CACHE_FILE = path.join(__dirname, ".audit-cache"); +const KNOWN_FILE = path.join(__dirname, "audit-known.json"); +const BLOCK_SEVERITIES = new Set(["critical", "high"]); + +const RED = "\x1b[31m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; + +function loadJson(filepath) { + try { + return JSON.parse(fs.readFileSync(filepath, "utf8")); + } catch { + return {}; + } +} + +function saveCache(cache) { + fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +function fetchAdvisory(ghsaId) { + return new Promise((resolve, reject) => { + const options = { + hostname: "api.github.com", + path: `/advisories/${ghsaId}`, + headers: { "User-Agent": "mike-audit-hook/1.0" }, + }; + https + .get(options, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + if (res.statusCode !== 200) { + reject(new Error(`GitHub API ${res.statusCode} for ${ghsaId}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }) + .on("error", reject); + }); +} + +function hoursAgo(isoDate) { + return (Date.now() - new Date(isoDate).getTime()) / (1000 * 60 * 60); +} + +function isExpired(entry) { + if (!entry || !entry.expires) return false; + return new Date(entry.expires) < new Date(); +} + +async function run() { + const targetDir = process.argv[2]; + if (!targetDir) { + console.error("Usage: audit-grace.cjs "); + process.exit(1); + } + + const absDir = path.resolve(targetDir); + const dirName = path.basename(absDir); + + if (!fs.existsSync(path.join(absDir, "package.json"))) { + console.log(` ${DIM}skip ${dirName} (no package.json)${RESET}`); + process.exit(0); + } + + let auditJson; + try { + const raw = execSync("npm audit --json 2>/dev/null", { + cwd: absDir, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + auditJson = JSON.parse(raw); + } catch (e) { + if (e.stdout) { + try { + auditJson = JSON.parse(e.stdout); + } catch { + console.error(` ${RED}failed to parse npm audit output for ${dirName}${RESET}`); + process.exit(1); + } + } else { + console.error(` ${RED}npm audit failed for ${dirName}${RESET}`); + process.exit(1); + } + } + + const vulns = auditJson.vulnerabilities || {}; + const known = loadJson(KNOWN_FILE); + const cache = loadJson(CACHE_FILE); + let cacheChanged = false; + + const findings = []; + + for (const [pkg, info] of Object.entries(vulns)) { + if (!BLOCK_SEVERITIES.has(info.severity)) continue; + + const ghsaIds = []; + for (const v of info.via || []) { + if (typeof v === "object" && v.url) { + const match = v.url.match(/(GHSA-[a-z0-9-]+)/); + if (match) ghsaIds.push(match[1]); + } + } + + if (ghsaIds.length === 0) { + findings.push({ + pkg, + severity: info.severity, + ghsa: "unknown", + status: "BLOCKED", + reason: "No GHSA ID found — cannot verify age", + }); + continue; + } + + for (const ghsa of ghsaIds) { + const knownEntry = known[ghsa] || known[`pkg:${pkg}`]; + if (knownEntry && !isExpired(knownEntry)) { + findings.push({ + pkg, + severity: info.severity, + ghsa, + status: "ALLOWED", + reason: knownEntry.reason || "In allowlist", + }); + continue; + } + if (knownEntry && isExpired(knownEntry)) { + findings.push({ + pkg, + severity: info.severity, + ghsa, + status: "BLOCKED", + reason: `Allowlist entry expired ${knownEntry.expires}`, + }); + continue; + } + + let publishedAt = null; + const cached = cache[ghsa]; + if (cached && hoursAgo(cached.fetched_at) < CACHE_TTL_DAYS * 24) { + publishedAt = cached.published_at; + } else { + try { + const advisory = await fetchAdvisory(ghsa); + publishedAt = advisory.published_at || advisory.created_at; + cache[ghsa] = { published_at: publishedAt, fetched_at: new Date().toISOString() }; + cacheChanged = true; + } catch (err) { + findings.push({ + pkg, + severity: info.severity, + ghsa, + status: "BLOCKED", + reason: `Cannot fetch advisory: ${err.message}`, + }); + continue; + } + } + + const ageHours = hoursAgo(publishedAt); + if (ageHours < GRACE_HOURS) { + findings.push({ + pkg, + severity: info.severity, + ghsa, + status: "GRACE", + reason: `Published ${Math.round(ageHours)}h ago (< ${GRACE_HOURS}h grace)`, + }); + } else { + findings.push({ + pkg, + severity: info.severity, + ghsa, + status: "BLOCKED", + reason: `Published ${Math.round(ageHours / 24)}d ago`, + }); + } + } + } + + if (cacheChanged) saveCache(cache); + + if (findings.length === 0) { + console.log(` ${GREEN}${dirName}: no critical/high vulnerabilities${RESET}`); + process.exit(0); + } + + console.log(`\n ${dirName} audit findings:`); + console.log(` ${"─".repeat(76)}`); + + for (const f of findings) { + const color = f.status === "BLOCKED" ? RED : f.status === "GRACE" ? YELLOW : GREEN; + const sev = f.severity.toUpperCase().padEnd(8); + const id = f.ghsa.padEnd(22); + const tag = `${color}${f.status.padEnd(7)}${RESET}`; + console.log(` ${sev} ${id} ${tag} ${DIM}${f.pkg}: ${f.reason}${RESET}`); + } + console.log(` ${"─".repeat(76)}\n`); + + const hasBlocked = findings.some((f) => f.status === "BLOCKED"); + const hasGrace = findings.some((f) => f.status === "GRACE"); + + if (hasBlocked) process.exit(1); + if (hasGrace) process.exit(2); + process.exit(0); +} + +run().catch((err) => { + console.error(` ${RED}audit-grace error: ${err.message}${RESET}`); + process.exit(1); +}); diff --git a/.githooks/audit-known.json b/.githooks/audit-known.json new file mode 100644 index 000000000..9a31ae756 --- /dev/null +++ b/.githooks/audit-known.json @@ -0,0 +1,30 @@ +{ + "pkg:protobufjs": { + "package": "protobufjs", + "severity": "high", + "reason": "Transitive dep via @google/genai. No upstream fix available yet.", + "added": "2026-05-17", + "expires": "2026-08-17" + }, + "pkg:@xmldom/xmldom": { + "package": "@xmldom/xmldom", + "severity": "high", + "reason": "Transitive dep via mammoth. No upstream fix available yet.", + "added": "2026-05-17", + "expires": "2026-08-17" + }, + "pkg:fast-xml-builder": { + "package": "fast-xml-builder", + "severity": "high", + "reason": "Transitive dep via @aws-sdk. No upstream fix available yet.", + "added": "2026-05-17", + "expires": "2026-08-17" + }, + "pkg:tmp": { + "package": "tmp", + "severity": "high", + "reason": "Transitive dep via libreoffice-convert. No upstream fix available yet.", + "added": "2026-05-17", + "expires": "2026-08-17" + } +} diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..439db702a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -uo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +DIM='\033[2m' +RESET='\033[0m' + +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOKS_DIR="$REPO_ROOT/.githooks" +PASS=0 +FAIL=0 +WARN=0 +RESULTS=() + +run_check() { + local name="$1" + shift + printf " ${DIM}running${RESET} %s ...\n" "$name" + local code=0 + "$@" || code=$? + if [[ $code -eq 0 ]]; then + RESULTS+=("${GREEN}PASS${RESET} $name") + ((PASS++)) + elif [[ "$name" == *"audit"* && $code -eq 2 ]]; then + RESULTS+=("${YELLOW}WARN${RESET} $name ${DIM}(grace period)${RESET}") + ((WARN++)) + else + RESULTS+=("${RED}FAIL${RESET} $name") + ((FAIL++)) + fi +} + +run_advisory() { + local name="$1" + shift + printf " ${DIM}running${RESET} %s ...\n" "$name" + local code=0 + "$@" || code=$? + if [[ $code -eq 0 ]]; then + RESULTS+=("${GREEN}PASS${RESET} $name") + ((PASS++)) + else + RESULTS+=("${YELLOW}WARN${RESET} $name ${DIM}(advisory only)${RESET}") + ((WARN++)) + fi +} + +echo "" +printf "${BOLD}pre-push checks${RESET}\n" +echo "────────────────────────────────────────" + +# 1. Security audits +run_check "security audit (backend)" node "$HOOKS_DIR/audit-grace.cjs" "$REPO_ROOT/backend" +run_check "security audit (frontend)" node "$HOOKS_DIR/audit-grace.cjs" "$REPO_ROOT/frontend" + +# 2. Type checking +run_check "typecheck (backend)" npx --prefix "$REPO_ROOT/backend" tsc --noEmit --project "$REPO_ROOT/backend/tsconfig.json" +run_check "typecheck (frontend)" npx --prefix "$REPO_ROOT/frontend" tsc --noEmit --project "$REPO_ROOT/frontend/tsconfig.json" + +# 3. Lint (advisory — does not block push until existing issues are resolved) +run_advisory "eslint (frontend)" npm run lint --prefix "$REPO_ROOT/frontend" + +# Summary +echo "" +echo "────────────────────────────────────────" +for r in "${RESULTS[@]}"; do + printf " %b\n" "$r" +done +echo "────────────────────────────────────────" + +if [[ $FAIL -gt 0 ]]; then + printf "\n ${RED}${BOLD}Push blocked${RESET}: %d check(s) failed.\n\n" "$FAIL" + exit 1 +fi + +if [[ $WARN -gt 0 ]]; then + printf "\n ${YELLOW}${BOLD}Warnings${RESET}: %d advisory(s) in grace period. Push allowed.\n\n" "$WARN" +fi + +printf " ${GREEN}${BOLD}All checks passed.${RESET}\n\n" +exit 0 diff --git a/.gitignore b/.gitignore index ce9161cee..2a53aa092 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ next-env.d.ts .DS_Store .vercel coverage + +# Git hook audit cache (generated by .githooks/audit-grace.cjs) +.githooks/.audit-cache diff --git a/README.md b/README.md index 9e70f9af6..cc934e7be 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,21 @@ Open `http://localhost:3000`. **DOC or DOCX conversion fails.** Install LibreOffice locally and restart the backend so document conversion commands are available on the process path. +## Git Hooks + +This repo ships shared git hooks for pre-push quality checks. Enable them once: + +```bash +git config core.hooksPath .githooks +``` + +What runs on push: +- **npm audit** — critical/high vulnerabilities are blocked, with a 24-hour grace period for newly published advisories +- **TypeScript type checking** — backend and frontend +- **ESLint** — frontend (advisory only; warns but does not block) + +Known/accepted vulnerabilities are documented in `.githooks/audit-known.json` with expiration dates. + ## Useful Checks ```bash diff --git a/backend/.env.example b/backend/.env.example index 6b4d56150..cd8a616e9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,5 +16,6 @@ R2_BUCKET_NAME=mike GEMINI_API_KEY=your-gemini-key ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key +CONCENTRATE_API_KEY=your-concentrate-key RESEND_API_KEY=your-resend-key USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret diff --git a/backend/migrations/001_add_concentrate_provider.sql b/backend/migrations/001_add_concentrate_provider.sql new file mode 100644 index 000000000..8b35677fc --- /dev/null +++ b/backend/migrations/001_add_concentrate_provider.sql @@ -0,0 +1,13 @@ +-- Add 'concentrate' as a valid provider in user_api_keys. +-- +-- PostgreSQL does not support adding a value to a CHECK constraint directly; +-- the constraint must be dropped and recreated. The table has no data +-- dependency on the old constraint shape — existing rows with provider in +-- ('claude', 'gemini', 'openai') continue to satisfy the new constraint. + +ALTER TABLE public.user_api_keys + DROP CONSTRAINT IF EXISTS user_api_keys_provider_check; + +ALTER TABLE public.user_api_keys + ADD CONSTRAINT user_api_keys_provider_check + CHECK (provider IN ('claude', 'gemini', 'openai', 'concentrate')); diff --git a/backend/schema.sql b/backend/schema.sql index b6a4e934a..df5308302 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -50,7 +50,7 @@ create trigger on_auth_user_created create table if not exists public.user_api_keys ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, - provider text not null check (provider in ('claude', 'gemini', 'openai')), + provider text not null check (provider in ('claude', 'gemini', 'openai', 'concentrate')), encrypted_key text not null, iv text not null, auth_tag text not null, diff --git a/backend/src/index.ts b/backend/src/index.ts index 07b3b8490..4b663ced3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,8 @@ import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; +import { concentrateModelsRouter } from "./routes/concentrateModels"; +import { providerModelsRouter } from "./routes/providerModels"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -118,6 +120,8 @@ app.use("/workflows", workflowsRouter); app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); +app.use("/concentrate/models", concentrateModelsRouter); +app.use("/providers", providerModelsRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/llm/catalogTypes.ts b/backend/src/lib/llm/catalogTypes.ts new file mode 100644 index 000000000..48c6a88f5 --- /dev/null +++ b/backend/src/lib/llm/catalogTypes.ts @@ -0,0 +1,57 @@ +/** + * Shared catalog types — the contract between the provider-models routes, + * the Concentrate models route, and the picker UI. + * + * The capability flags are the load-bearing piece. A model can only be + * shown in Mike's chat picker if `capabilities.chat === true`. Capabilities + * default to FALSE — every fetcher has to prove a model is chat-capable + * by setting the flag explicitly. This is the safety net: when a provider + * ships a new model class (audio dialog, image generation, embedding, etc.) + * Mike's UI hides it by default rather than letting it slip through and + * fail at request time. + */ + +export type ModelCapabilities = { + /** + * Text in -> text out. The minimum bar for the chat picker. Picker + * surfaces only models where this is true. + */ + chat: boolean; + + /** + * Supports function/tool calling. Required by Mike's agentic tabular + * review and assistant tool surfaces. Some chat models (early o1) do + * not, even though they support text in/out — those should still appear + * in the picker for simple completions but be marked unavailable to + * tool-using callers. + */ + tools: boolean; + + /** + * Supports server-sent event streaming. Mike's chat UI assumes + * streaming and would silently buffer the whole response otherwise. + */ + streaming: boolean; +}; + +export type ProviderCatalogModel = { + /** Bare model slug as recognized by the routing layer. */ + id: string; + /** Display label shown in the picker. */ + label: string; + /** Author group for the picker section header. */ + group: "Anthropic" | "Google" | "OpenAI" | string; + /** True when at least one Concentrate-side provider is ZDR-certified. */ + zdr?: boolean; + /** + * Strict capability flags. Defaults to all-false; the fetcher must + * explicitly opt a model in. + */ + capabilities: ModelCapabilities; +}; + +export const ZERO_CAPABILITIES: ModelCapabilities = { + chat: false, + tools: false, + streaming: false, +}; diff --git a/backend/src/lib/llm/concentrate.ts b/backend/src/lib/llm/concentrate.ts new file mode 100644 index 000000000..9d272ec56 --- /dev/null +++ b/backend/src/lib/llm/concentrate.ts @@ -0,0 +1,331 @@ +/** + * Concentrate AI provider — universal model router. + * + * Concentrate exposes an OpenAI-Responses-compatible endpoint that proxies + * to many underlying model authors (Anthropic, Google, OpenAI, Meta, …) + * behind a single API key. This adapter is structurally the same as + * backend/src/lib/llm/openai.ts (same Responses API, same SSE event stream) + * but with a different base URL and auth key. + * + * The base URL can be overridden via CONCENTRATE_RESPONSES_URL for staging + * or self-hosted Concentrate instances. + */ +import type { + LlmMessage, + NormalizedToolCall, + NormalizedToolResult, + OpenAIToolSchema, + StreamChatParams, + StreamChatResult, +} from "./types"; + +const DEFAULT_RESPONSES_URL = "https://api.concentrate.ai/v1/responses"; +const MAX_OUTPUT_TOKENS = 16384; + +function responsesUrl(): string { + return process.env.CONCENTRATE_RESPONSES_URL?.trim() || DEFAULT_RESPONSES_URL; +} + +type ResponseInputItem = + | { role: "user" | "assistant"; content: string } + | { type: "function_call"; call_id: string; name: string; arguments: string } + | { type: "function_call_output"; call_id: string; output: string }; + +type ResponseFunctionTool = { + type: "function"; + name: string; + description?: string; + parameters: Record; +}; + +type ResponseFunctionCallItem = { + type: "function_call"; + call_id?: string; + name?: string; + arguments?: string; +}; + +type ResponseStreamEvent = { + type?: string; + delta?: string; + response?: { id?: string; output_text?: string }; + item?: ResponseFunctionCallItem; +}; + +function apiKey(override?: string | null): string { + const key = override?.trim() || process.env.CONCENTRATE_API_KEY?.trim() || ""; + if (!key) { + throw new Error( + "Concentrate API key is not configured. Set CONCENTRATE_API_KEY or add a user Concentrate key.", + ); + } + return key; +} + +function toResponseTools(tools: OpenAIToolSchema[]): ResponseFunctionTool[] { + return tools.map((tool) => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + })); +} + +function toResponseInput(messages: LlmMessage[]): ResponseInputItem[] { + return messages.map((message) => ({ + role: message.role, + content: message.content, + })); +} + +function extractSseJson(buffer: string): { events: unknown[]; rest: string } { + const events: unknown[] = []; + const chunks = buffer.split(/\n\n/); + const rest = chunks.pop() ?? ""; + + for (const chunk of chunks) { + const dataLines = chunk + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()); + + for (const data of dataLines) { + if (!data || data === "[DONE]") continue; + try { + events.push(JSON.parse(data)); + } catch { + // Incomplete events stay buffered until the next read. + } + } + } + + return { events, rest }; +} + +function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall { + let input: Record = {}; + try { + const parsed = JSON.parse(item.arguments || "{}"); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + input = parsed as Record; + } + } catch { + input = {}; + } + + return { + id: item.call_id ?? item.name ?? "function_call", + name: item.name ?? "", + input, + }; +} + +async function createResponse(params: { + model: string; + input: ResponseInputItem[]; + instructions?: string; + tools?: ResponseFunctionTool[]; + stream?: boolean; + maxTokens?: number; + previousResponseId?: string; + reasoningSummary?: boolean; + apiKey: string; +}): Promise { + const response = await fetch(responsesUrl(), { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: params.model, + instructions: params.instructions || undefined, + input: params.input, + tools: params.tools?.length ? params.tools : undefined, + stream: params.stream, + max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS, + previous_response_id: params.previousResponseId, + reasoning: params.reasoningSummary ? { summary: "auto" } : undefined, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + const err = new Error( + `Concentrate request failed (${response.status}): ${text || response.statusText}`, + ); + (err as { status?: number }).status = response.status; + throw err; + } + + return response; +} + +export async function streamConcentrate( + params: StreamChatParams, +): Promise { + const { + model, + systemPrompt, + tools = [], + callbacks = {}, + runTools, + apiKeys, + enableThinking, + } = params; + const maxIter = params.maxIterations ?? 10; + const key = apiKey(apiKeys?.concentrate); + const responseTools = toResponseTools(tools); + let input = toResponseInput(params.messages); + let previousResponseId: string | undefined; + let fullText = ""; + const hasTools = responseTools.length > 0; + + for (let iter = 0; iter < maxIter; iter++) { + const response = await createResponse({ + model, + instructions: iter === 0 ? systemPrompt : undefined, + input, + tools: responseTools, + stream: true, + previousResponseId, + reasoningSummary: !!enableThinking, + apiKey: key, + }); + if (!response.body) throw new Error("Concentrate response had no body"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const toolCalls: NormalizedToolCall[] = []; + const startedToolCallIds = new Set(); + let buffer = ""; + let pendingText = ""; + let sawReasoning = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const extracted = extractSseJson(buffer); + buffer = extracted.rest; + + for (const event of extracted.events as ResponseStreamEvent[]) { + if (event.response?.id) { + previousResponseId = event.response.id; + } + + if ( + event.type === "response.reasoning_summary_text.delta" && + typeof event.delta === "string" + ) { + sawReasoning = true; + callbacks.onReasoningDelta?.(event.delta); + } + + if ( + event.type === "response.output_text.delta" && + typeof event.delta === "string" + ) { + if (hasTools) { + pendingText += event.delta; + } else { + fullText += event.delta; + callbacks.onContentDelta?.(event.delta); + } + } + + if ( + event.type === "response.output_item.added" && + event.item?.type === "function_call" + ) { + const call = parseFunctionCall(event.item); + startedToolCallIds.add(call.id); + callbacks.onToolCallStart?.(call); + } + + if ( + event.type === "response.output_item.done" && + event.item?.type === "function_call" + ) { + const call = parseFunctionCall(event.item); + if (!startedToolCallIds.has(call.id)) { + callbacks.onToolCallStart?.(call); + } + toolCalls.push(call); + } + } + } + + if (sawReasoning) callbacks.onReasoningBlockEnd?.(); + + if (!toolCalls.length || !runTools) { + if (pendingText) { + fullText += pendingText; + callbacks.onContentDelta?.(pendingText); + } + break; + } + + const results = await runTools(toolCalls); + + // Preserve the conversation: the assistant's function_call items must + // travel back alongside the function_call_output items, otherwise the + // Responses API loses track of which call each output answers. + // Clear previousResponseId so the next request carries the full input + // inline rather than relying on a stored response that lacks the + // outputs we just produced. + input = [ + ...input, + ...toolCalls.map((call) => ({ + type: "function_call" as const, + call_id: call.id, + name: call.name, + arguments: JSON.stringify(call.input), + })), + ...results.map((result) => ({ + type: "function_call_output" as const, + call_id: result.tool_use_id, + output: result.content, + })), + ]; + previousResponseId = undefined; + } + + return { fullText }; +} + +export async function completeConcentrateText(params: { + model: string; + systemPrompt?: string; + user: string; + maxTokens?: number; + apiKeys?: { concentrate?: string | null }; +}): Promise { + const response = await createResponse({ + model: params.model, + instructions: params.systemPrompt, + input: [{ role: "user", content: params.user }], + maxTokens: params.maxTokens ?? 512, + apiKey: apiKey(params.apiKeys?.concentrate), + }); + const json = (await response.json()) as { + output_text?: string; + output?: { + content?: { type?: string; text?: string }[]; + }[]; + }; + + if (typeof json.output_text === "string") return json.output_text; + + return ( + json.output + ?.flatMap((item) => item.content ?? []) + .filter((content) => content.type === "output_text") + .map((content) => content.text ?? "") + .join("") ?? "" + ); +} + +export type { NormalizedToolResult }; diff --git a/backend/src/lib/llm/concentrateCatalog.ts b/backend/src/lib/llm/concentrateCatalog.ts new file mode 100644 index 000000000..f6ca0121d --- /dev/null +++ b/backend/src/lib/llm/concentrateCatalog.ts @@ -0,0 +1,38 @@ +/** + * Process-local cache of the Concentrate ZDR-tagged model catalog. + * + * The /concentrate/models route refreshes this cache (5-minute TTL) so the + * router in lib/llm/index.ts can ask "is this model ID ZDR via Concentrate?" + * without doing its own HTTP fetch. When a Concentrate key is configured and + * a model is in this set, routing prefers Concentrate over direct provider + * keys so the ZDR guarantee in the picker UI is actually enforced. + * + * If the cache hasn't been populated yet (no /concentrate/models call has + * happened this process lifetime), isZdr() returns false and routing falls + * back to "use direct key if available." The cache will populate on the + * first /concentrate/models request after the user opens the picker. + */ +export type ConcentrateZdrEntry = { + /** Bare model slug as returned by Concentrate (e.g. claude-opus-4-5). */ + id: string; +}; + +let zdrIds: Set = new Set(); +let lastUpdatedAt = 0; + +export function setZdrModels(models: ConcentrateZdrEntry[]): void { + zdrIds = new Set(models.map((m) => m.id).filter(Boolean)); + lastUpdatedAt = Date.now(); +} + +export function isZdrViaConcentrate(modelId: string): boolean { + return zdrIds.has(modelId); +} + +export function zdrCatalogLastUpdated(): number { + return lastUpdatedAt; +} + +export function zdrCatalogSize(): number { + return zdrIds.size; +} diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 4b5e97936..5c930af44 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,19 +1,75 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; import { streamOpenAI, completeOpenAIText } from "./openai"; +import { streamConcentrate, completeConcentrateText } from "./concentrate"; import { providerForModel } from "./models"; +import { isZdrViaConcentrate } from "./concentrateCatalog"; import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; export * from "./types"; export * from "./models"; +/** + * Resolve a model id to a provider. + * + * Routing rules (in order): + * 1. Unknown prefix (not claude-, gemini-, gpt-, o1/o3/o4) routes to + * Concentrate when a Concentrate key is configured. These are + * non-frontier models only available via the Concentrate router. + * 2. ZDR precedence: when Concentrate's catalog tags this model as ZDR + * and the user has a Concentrate key, route through Concentrate + * regardless of direct provider key. This enforces the privacy + * contract advertised by the ZDR badge in the picker — once a model + * is in the ZDR list, prompts and outputs never touch the direct + * provider on this user's behalf. + * 3. Direct provider key wins for non-ZDR models. New frontier releases + * that Concentrate has not yet certified for ZDR continue to route + * through the user's direct key, so users don't lose access to + * brand-new models while waiting for ZDR certification. + * 4. No direct key + Concentrate key configured → fall back to + * Concentrate as a universal router (non-ZDR, may not be private). + * 5. No key at all → route to native provider so it surfaces a clear + * missing-key error. + */ +function pick( + model: string, + apiKeys: UserApiKeys | undefined, +): { provider: "claude" | "gemini" | "openai" | "concentrate"; slug: string } { + const native = providerForModel(model); + + // Rule 1 — unknown prefix, only Concentrate can dispatch. + if (native === "concentrate") { + return { provider: "concentrate", slug: model }; + } + + // Rule 2 — ZDR precedence. Concentrate's catalog is authoritative on + // which models are ZDR-certified; if the user has a Concentrate key + // they get the privacy guarantee. + if (apiKeys?.concentrate && isZdrViaConcentrate(model)) { + return { provider: "concentrate", slug: model }; + } + + // Rule 3 — direct provider key for non-ZDR (or not-yet-cached) model. + if (native === "claude" && apiKeys?.claude) return { provider: "claude", slug: model }; + if (native === "gemini" && apiKeys?.gemini) return { provider: "gemini", slug: model }; + if (native === "openai" && apiKeys?.openai) return { provider: "openai", slug: model }; + + // Rule 4 — Concentrate as a fallback router when no direct key exists. + if (apiKeys?.concentrate) return { provider: "concentrate", slug: model }; + + // Rule 5 — native provider so it surfaces a clear missing-key error. + return { provider: native, slug: model }; +} + export async function streamChatWithTools( params: StreamChatParams, ): Promise { - const provider = providerForModel(params.model); - if (provider === "claude") return streamClaude(params); - if (provider === "openai") return streamOpenAI(params); - return streamGemini(params); + const { provider, slug } = pick(params.model, params.apiKeys); + const p = { ...params, model: slug }; + if (provider === "concentrate") return streamConcentrate(p); + if (provider === "claude") return streamClaude(p); + if (provider === "openai") return streamOpenAI(p); + return streamGemini(p); } export async function completeText(params: { @@ -23,8 +79,10 @@ export async function completeText(params: { maxTokens?: number; apiKeys?: UserApiKeys; }): Promise { - const provider = providerForModel(params.model); - if (provider === "claude") return completeClaudeText(params); - if (provider === "openai") return completeOpenAIText(params); - return completeGeminiText(params); + const { provider, slug } = pick(params.model, params.apiKeys); + const p = { ...params, model: slug }; + if (provider === "concentrate") return completeConcentrateText(p); + if (provider === "claude") return completeClaudeText(p); + if (provider === "openai") return completeOpenAIText(p); + return completeGeminiText(p); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index ed4872eff..1e54dfed3 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -42,14 +42,29 @@ const ALL_MODELS = new Set([ // Provider inference // --------------------------------------------------------------------------- +// Returns the native provider for a model ID by prefix. Unknown prefixes +// (e.g. Concentrate-only authors like meta/, mistral/) fall through to +// "concentrate" as the catch-all router. export function providerForModel(model: string): Provider { if (model.startsWith("claude")) return "claude"; if (model.startsWith("gemini")) return "gemini"; if (model.startsWith("gpt-")) return "openai"; - throw new Error(`Unknown model id: ${model}`); + if (/^o[1-9]/.test(model)) return "openai"; + if (model.endsWith("-chat-latest") || model === "chat-latest") return "openai"; + return "concentrate"; } +export function isStaticModel(id: string): boolean { + return ALL_MODELS.has(id); +} + +// Accept any plausible model ID — static or from a live catalog. The static +// allowlist used to double as validation but that gating now lives in pick() +// (provider key check) and looksLikeModelId() on the frontend. Restricting +// to ALL_MODELS here would silently downgrade dynamic catalog selections. +const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9./:_-]{0,199}$/; + export function resolveModel(id: string | null | undefined, fallback: string): string { - if (id && ALL_MODELS.has(id)) return id; + if (id && MODEL_ID_RE.test(id)) return id; return fallback; } diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index a8409d80e..baac15a03 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -2,7 +2,7 @@ // Callers always speak OpenAI-style tools + { role, content } messages; each // provider translates internally. -export type Provider = "claude" | "gemini" | "openai"; +export type Provider = "claude" | "gemini" | "openai" | "concentrate"; export type OpenAIToolSchema = { type: "function"; @@ -40,6 +40,7 @@ export type UserApiKeys = { claude?: string | null; gemini?: string | null; openai?: string | null; + concentrate?: string | null; }; export type StreamChatParams = { diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts index cbc3153fe..883619697 100644 --- a/backend/src/lib/userApiKeys.ts +++ b/backend/src/lib/userApiKeys.ts @@ -3,7 +3,7 @@ import { createServerSupabase } from "./supabase"; import type { UserApiKeys } from "./llm"; type Db = ReturnType; -export type ApiKeyProvider = "claude" | "gemini" | "openai"; +export type ApiKeyProvider = "claude" | "gemini" | "openai" | "concentrate"; export type ApiKeySource = "user" | "env" | null; export type ApiKeyStatus = Record & { sources: Record; @@ -16,7 +16,7 @@ type EncryptedKeyRow = { auth_tag: string; }; -const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"]; +const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai", "concentrate"]; function envApiKey(provider: ApiKeyProvider): string | null { if (provider === "claude") { @@ -29,6 +29,9 @@ function envApiKey(provider: ApiKeyProvider): string | null { if (provider === "openai") { return process.env.OPENAI_API_KEY?.trim() || null; } + if (provider === "concentrate") { + return process.env.CONCENTRATE_API_KEY?.trim() || null; + } return process.env.GEMINI_API_KEY?.trim() || null; } @@ -96,10 +99,12 @@ export async function getUserApiKeyStatus( claude: false, gemini: false, openai: false, + concentrate: false, sources: { claude: null, gemini: null, openai: null, + concentrate: null, }, }; @@ -135,6 +140,7 @@ export async function getUserApiKeys( claude: envApiKey("claude"), gemini: envApiKey("gemini"), openai: envApiKey("openai"), + concentrate: envApiKey("concentrate"), }; const { data, error } = await db diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index bfbeb0fd5..fe61db2d5 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -22,6 +22,8 @@ function resolveTitleModel(apiKeys: UserApiKeys): string { if (apiKeys.gemini?.trim()) return DEFAULT_TITLE_MODEL; if (apiKeys.openai?.trim()) return OPENAI_LOW_MODELS[0]; if (apiKeys.claude?.trim()) return "claude-haiku-4-5"; + // Concentrate routes the default slug to a low-tier model internally. + if (apiKeys.concentrate?.trim()) return DEFAULT_TITLE_MODEL; return DEFAULT_TITLE_MODEL; } diff --git a/backend/src/routes/concentrateModels.ts b/backend/src/routes/concentrateModels.ts new file mode 100644 index 000000000..6307e65ce --- /dev/null +++ b/backend/src/routes/concentrateModels.ts @@ -0,0 +1,229 @@ +/** + * GET /concentrate/models + * + * Fetches the user's authorized Concentrate model catalog. Returns an + * empty list when no Concentrate key is configured. Results are cached + * in-process for 5 minutes. + * + * Two filters apply server-side: + * 1. At least one Concentrate-side provider must be ZDR-certified + * (Concentrate is positioned as the privacy lane in Mike's picker, + * so non-ZDR-only models would defeat the purpose). + * 2. The model must be chat-capable per its supports object — text + * input is supported AND the model is not an embedding / image-only + * / TTS / audio-only model. Capabilities are computed here from + * Concentrate's authoritative supports payload and returned to the + * frontend on every model, so the picker doesn't need provider- + * specific gating logic of its own. + */ +import { Router, type Request, type Response } from "express"; +import { requireAuth } from "../middleware/auth"; +import { getUserApiKeys } from "../lib/userSettings"; +import { setZdrModels } from "../lib/llm/concentrateCatalog"; +import { + ZERO_CAPABILITIES, + type ModelCapabilities, + type ProviderCatalogModel, +} from "../lib/llm/catalogTypes"; + +export const concentrateModelsRouter = Router(); + +const DEFAULT_MODELS_URL = "https://api.concentrate.ai/v1/models"; + +function modelsUrl(): string { + const responses = process.env.CONCENTRATE_RESPONSES_URL?.trim(); + if (responses) return responses.replace(/\/responses$/, "/models"); + return DEFAULT_MODELS_URL; +} + +type CacheEntry = { + models: ProviderCatalogModel[]; + fetchedAt: number; +}; +const CACHE_TTL_MS = 5 * 60 * 1000; +let cache: CacheEntry | null = null; + +function resolveKey(userKey?: string | null): string { + return userKey?.trim() || process.env.CONCENTRATE_API_KEY?.trim() || ""; +} + +// --------------------------------------------------------------------------- +// Concentrate response shape (only the parts we read) +// --------------------------------------------------------------------------- + +type ZdrInfo = false | { policy_url?: string; certificate_url?: string }; + +type SupportsInput = { + text?: boolean; + image?: Record; + file?: Record; + audio?: Record; +}; + +type SupportsTools = { + function_calling?: boolean; +}; + +type RawProvider = { + zdr?: ZdrInfo; + supports?: { + input?: SupportsInput; + tools?: SupportsTools; + streaming?: boolean; + }; +}; + +type RawModel = { + slug?: string; + name?: string; + author?: { slug?: string; display_name?: string }; + providers?: Record; +}; + +// --------------------------------------------------------------------------- +// Capability + ZDR derivation +// --------------------------------------------------------------------------- + +function authorGroup(slug: string, displayName?: string): string { + if (displayName) return displayName; + if (slug === "anthropic") return "Anthropic"; + if (slug === "openai") return "OpenAI"; + if (slug === "google") return "Google"; + return slug.charAt(0).toUpperCase() + slug.slice(1); +} + +function isZdrProvider(p: RawProvider): boolean { + return !!p.zdr; +} + +/** + * Derive capabilities from Concentrate's provider supports payload. A + * model is chat-capable iff at least one ZDR provider for it advertises + * text input. We require ZDR specifically (not just any provider with + * text) because Mike only routes ZDR models through Concentrate — if + * the only text-supporting provider is non-ZDR, Mike wouldn't route to + * it through this lane anyway. + * + * The slug itself is a backstop signal: image/audio/TTS/embedding model + * families get their capabilities zeroed even if the supports object + * accidentally says otherwise, because Mike's adapter can't drive them. + */ +function deriveCapabilities(m: RawModel): ModelCapabilities { + const slug = m.slug ?? ""; + + // Slug-shape blocklist — applies regardless of what supports says. + const slugLower = slug.toLowerCase(); + const nonChatHints = [ + "embedding", + "embed", + "tts", + "whisper", + "transcribe", + "image", + "vision-only", + "moderation", + "audio-", + "-audio", + "realtime", + "dall-e", + ]; + for (const bad of nonChatHints) { + if (slugLower.includes(bad)) return { ...ZERO_CAPABILITIES }; + } + + const providers = m.providers ?? {}; + let chat = false; + let tools = false; + let streaming = false; + for (const p of Object.values(providers)) { + if (!isZdrProvider(p)) continue; + const sup = p.supports ?? {}; + if (sup.input?.text === true) chat = true; + if (sup.tools?.function_calling === true) tools = true; + // Concentrate doesn't expose a streaming flag explicitly in the + // supports map we've sampled. Streaming through the Responses API + // is supported uniformly by all of Concentrate's ZDR providers, + // so when chat is true we treat streaming as true. If that turns + // out wrong for some future entry, this is the one place to fix. + if (chat) streaming = true; + } + return { chat, tools, streaming }; +} + +// --------------------------------------------------------------------------- +// Fetch + transform +// --------------------------------------------------------------------------- + +async function fetchModelsFromApi( + key: string, +): Promise { + const res = await fetch(modelsUrl(), { + headers: { Authorization: `Bearer ${key}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Concentrate /v1/models failed (${res.status}): ${text || res.statusText}`, + ); + } + const json = await res.json(); + const raw = Array.isArray(json) + ? json + : ((json as { data?: unknown[] }).data ?? []); + + return (raw as RawModel[]) + .filter((m) => { + // Need at least one ZDR provider for the model to be relevant + // in Mike's privacy-first Concentrate lane. + const providers = m.providers ?? {}; + return Object.values(providers).some(isZdrProvider); + }) + .map((m) => ({ + id: m.slug ?? "", + label: m.name ?? m.slug ?? "", + group: authorGroup( + m.author?.slug ?? "unknown", + m.author?.display_name, + ), + zdr: true, + capabilities: deriveCapabilities(m), + })) + .filter((m) => !!m.id); +} + +concentrateModelsRouter.get( + "/", + requireAuth, + async (_req: Request, res: Response): Promise => { + try { + const userId = res.locals.userId as string; + const apiKeys = await getUserApiKeys(userId); + const key = resolveKey(apiKeys.concentrate); + + if (!key) { + res.json({ models: [] }); + return; + } + + if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { + res.json({ models: cache.models }); + return; + } + + const models = await fetchModelsFromApi(key); + cache = { models, fetchedAt: Date.now() }; + // Publish the chat-capable ZDR set to the routing layer so chat + // requests can enforce "ZDR routes through Concentrate" server + // side, and reject non-chat IDs entirely. + setZdrModels( + models + .filter((m) => m.capabilities.chat) + .map((m) => ({ id: m.id })), + ); + res.json({ models }); + } catch (err) { + console.error("[concentrate-models]", err); + res.json({ models: cache?.models ?? [] }); + } + }, +); diff --git a/backend/src/routes/providerModels.ts b/backend/src/routes/providerModels.ts new file mode 100644 index 000000000..b3805c513 --- /dev/null +++ b/backend/src/routes/providerModels.ts @@ -0,0 +1,333 @@ +/** + * GET /providers/:provider/models + * + * Returns the authorized model catalog for a direct provider (anthropic, + * google, openai) with capability flags attached. Returns an empty list + * when no key is configured. + * + * The picker filters on capabilities.chat — see catalogTypes.ts. Each + * fetcher below populates capabilities EXPLICITLY from the provider's own + * signals; defaults are all-false so a new model class that nobody has + * taught Mike about is hidden by default rather than slipping through. + */ +import { Router, type Request, type Response } from "express"; +import { requireAuth } from "../middleware/auth"; +import { getUserApiKeys } from "../lib/userSettings"; +import { + ZERO_CAPABILITIES, + type ProviderCatalogModel, +} from "../lib/llm/catalogTypes"; + +export const providerModelsRouter = Router(); + +type CacheEntry = { models: ProviderCatalogModel[]; fetchedAt: number }; +const CACHE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +function cacheKey(provider: string, key: string): string { + return `${provider}:${key.slice(0, 8)}`; +} + +// --------------------------------------------------------------------------- +// Anthropic +// --------------------------------------------------------------------------- +// +// Anthropic's /v1/models endpoint only returns chat-capable Claude models — +// they ship embeddings and other modalities behind separate product lines. +// Every claude- model returned supports streaming and tool use per Anthropic +// docs. Confidence on these capabilities is high. + +type AnthropicModel = { + type?: string; + id?: string; + display_name?: string; +}; + +async function fetchAnthropic(key: string): Promise { + const res = await fetch("https://api.anthropic.com/v1/models?limit=100", { + headers: { + "x-api-key": key, + "anthropic-version": "2023-06-01", + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Anthropic /v1/models failed (${res.status}): ${text || res.statusText}`, + ); + } + const json = (await res.json()) as { data?: AnthropicModel[] }; + const all = (json.data ?? []).filter( + (m) => !!m.id && m.id.startsWith("claude-"), + ); + + const bareSlug = (id: string) => id.replace(/-\d{8}$/, ""); + const cleanLabel = (s: string) => + s.replace(/\s*\(\d{4}-\d{2}-\d{2}\)\s*$/, ""); + + const seen = new Map(); + for (const m of all) { + const id = bareSlug(m.id!); + if (seen.has(id)) continue; + seen.set(id, { + id, + label: cleanLabel(m.display_name ?? m.id!), + group: "Anthropic", + capabilities: { chat: true, tools: true, streaming: true }, + }); + } + return [...seen.values()]; +} + +// --------------------------------------------------------------------------- +// OpenAI +// --------------------------------------------------------------------------- +// +// OpenAI's /v1/models is the weakest signal of the three — no capability +// flags, no modality info, just IDs. We have to encode our knowledge of +// each family explicitly. Anything not in the chat-family allowlist is +// hidden. When OpenAI ships a new family (e.g. "o5"), it won't appear in +// the picker until this list is extended — which is the correct failure +// mode (hidden, not broken). + +type OpenAIModel = { + id?: string; + object?: string; + owned_by?: string; +}; + +function openaiLabel(id: string): string { + if (id === "chat-latest") return "GPT-latest"; + return id + .replace(/^gpt-/, "GPT-") + .replace(/-chat-latest$/, "-latest") + .replace(/-mini\b/i, " Mini") + .replace(/-nano\b/i, " Nano") + .replace(/-turbo\b/i, " Turbo"); +} + +/** + * Per-family capability table. Each entry is a regex that matches a family + * of model IDs plus the capabilities we know that family supports through + * the Responses API (which is what Mike calls). Entries are evaluated in + * order; the first match wins. Add a new entry when OpenAI ships a new + * chat family. Anything not matched is hidden. + */ +const OPENAI_FAMILIES: Array<{ + test: RegExp; + chat: boolean; + tools: boolean; + streaming: boolean; +}> = [ + // chat-latest aliases (chat-latest, gpt-5-chat-latest, gpt-5.1-chat-latest, etc.) + { test: /^(gpt-[\w.]*-)?chat-latest$/, chat: true, tools: true, streaming: true }, + // gpt-5* family — base, versioned, dated, and pro variants + { test: /^gpt-5(\.\d+)?(-(mini|nano|turbo|pro))?(-\d{4}-\d{2}-\d{2}|-\d+)?$/, chat: true, tools: true, streaming: true }, + // gpt-4o, gpt-4.1, gpt-4-turbo etc. with optional date stamp + { test: /^gpt-4(o|\.1|\.5)?(-(mini|nano|turbo))?(-\d{4}-\d{2}-\d{2})?$/, chat: true, tools: true, streaming: true }, + // o-series reasoning models: o1, o3, o4-mini, o1-pro, o3-pro, dated variants + { test: /^o[1-9](-(mini|preview|pro))?(-\d{4}-\d{2}-\d{2})?$/, chat: true, tools: true, streaming: true }, +]; + +function openaiCapabilities(id: string): ProviderCatalogModel["capabilities"] { + // Hard blocklist for non-chat surfaces that share the gpt-/o prefix. + if ( + id.includes("audio") || + id.includes("image") || + id.includes("tts") || + id.includes("whisper") || + id.includes("transcribe") || + id.includes("embedding") || + id.includes("moderation") || + id.includes("realtime") || + id.includes("search") || + id.includes("codex") || + id.includes("deep-research") || + id.startsWith("ft:") + ) { + return { ...ZERO_CAPABILITIES }; + } + for (const family of OPENAI_FAMILIES) { + if (family.test.test(id)) { + return { + chat: family.chat, + tools: family.tools, + streaming: family.streaming, + }; + } + } + return { ...ZERO_CAPABILITIES }; +} + +async function fetchOpenAI(key: string): Promise { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${key}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `OpenAI /v1/models failed (${res.status}): ${text || res.statusText}`, + ); + } + const json = (await res.json()) as { data?: OpenAIModel[] }; + return (json.data ?? []) + .filter((m) => !!m.id) + .map((m) => ({ + id: m.id!, + label: openaiLabel(m.id!), + group: "OpenAI" as const, + capabilities: openaiCapabilities(m.id!), + })); +} + +// --------------------------------------------------------------------------- +// Google (Gemini) +// --------------------------------------------------------------------------- +// +// Google's /v1beta/models is mixed: chat, image generation, TTS, audio +// dialog, embeddings, AQA, and Live API all live in one list. We use two +// signals together: supportedGenerationMethods includes generateContent +// (necessary) AND the ID does NOT match a specialized-purpose substring +// (sufficient). When Google ships a new specialized variant, the new +// substring needs to be added here — but a chat variant with a NEW name +// pattern will still be flagged chat:true correctly because the only +// thing it can fail is the substring blocklist, not the positive signal. + +type GeminiModel = { + name?: string; + displayName?: string; + supportedGenerationMethods?: string[]; +}; + +const GEMINI_NON_CHAT_SUBSTRINGS = [ + "image", // Nano Banana et al — image generation + "vision", // legacy vision-only + "tts", // text-to-speech + "audio", // native audio dialog + "embed", // embeddings + "embedding", // embeddings (alt spelling) + "aqa", // attributed question answering + "live", // Live API streaming + "thinking", // experimental thinking dialog + "realtime", // realtime API +]; + +function geminiCapabilities( + id: string, + methods: string[], +): ProviderCatalogModel["capabilities"] { + if (!methods.includes("generateContent")) return { ...ZERO_CAPABILITIES }; + for (const bad of GEMINI_NON_CHAT_SUBSTRINGS) { + if (id.includes(bad)) return { ...ZERO_CAPABILITIES }; + } + if (id.startsWith("gemini-1")) return { ...ZERO_CAPABILITIES }; + if (/^gemini-2\.0(-|$)/.test(id)) return { ...ZERO_CAPABILITIES }; + if (/-preview-\d+/.test(id) || /-exp-\d+/.test(id)) { + return { ...ZERO_CAPABILITIES }; + } + // Every surviving Gemini chat model supports function calling and + // streaming via the SDK we use (@google/genai). + return { chat: true, tools: true, streaming: true }; +} + +async function fetchGemini(key: string): Promise { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?pageSize=200&key=${encodeURIComponent(key)}`, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Google /v1beta/models failed (${res.status}): ${text || res.statusText}`, + ); + } + const json = (await res.json()) as { models?: GeminiModel[] }; + return (json.models ?? []) + .filter((m) => (m.name ?? "").startsWith("models/gemini-")) + .map((m) => { + const id = m.name!.replace(/^models\//, ""); + return { + id, + label: m.displayName ?? id, + group: "Google" as const, + capabilities: geminiCapabilities( + id, + m.supportedGenerationMethods ?? [], + ), + }; + }); +} + +// --------------------------------------------------------------------------- +// Route +// --------------------------------------------------------------------------- + +type ProviderConfig = { + fetch: (key: string) => Promise; + keyField: "claude" | "gemini" | "openai"; + envKey: string[]; +}; + +const PROVIDERS: Record = { + anthropic: { + fetch: fetchAnthropic, + keyField: "claude", + envKey: ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], + }, + openai: { + fetch: fetchOpenAI, + keyField: "openai", + envKey: ["OPENAI_API_KEY"], + }, + google: { + fetch: fetchGemini, + keyField: "gemini", + envKey: ["GEMINI_API_KEY"], + }, +}; + +function resolveKey(envKeys: string[], userKey?: string | null): string { + const fromUser = userKey?.trim(); + if (fromUser) return fromUser; + for (const name of envKeys) { + const v = process.env[name]?.trim(); + if (v) return v; + } + return ""; +} + +providerModelsRouter.get( + "/:provider/models", + requireAuth, + async (req: Request, res: Response): Promise => { + const provider = req.params.provider; + const config = PROVIDERS[provider]; + if (!config) { + res.status(404).json({ models: [], detail: "Unknown provider" }); + return; + } + + try { + const userId = res.locals.userId as string; + const apiKeys = await getUserApiKeys(userId); + const key = resolveKey(config.envKey, apiKeys[config.keyField]); + if (!key) { + res.json({ models: [] }); + return; + } + + const ck = cacheKey(provider, key); + const hit = cache.get(ck); + if (hit && Date.now() - hit.fetchedAt < CACHE_TTL_MS) { + res.json({ models: hit.models }); + return; + } + + const models = await config.fetch(key); + cache.set(ck, { models, fetchedAt: Date.now() }); + res.json({ models }); + } catch (err) { + console.error(`[provider-models:${provider}]`, err); + res.json({ models: [] }); + } + }, +); diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index c83d68144..dee9ffc97 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { AlertCircle, Check, ChevronDown, Eye, EyeOff } from "lucide-react"; +import { AlertCircle, Check, ChevronDown, Eye, EyeOff, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -20,6 +20,8 @@ import { modelGroupToProvider, providerLabel, } from "@/app/lib/modelAvailability"; +import { clearProviderModelsCache } from "@/app/lib/providerModels"; +import { clearConcentrateModelsCache } from "@/app/lib/concentrateModels"; const API_KEY_FIELDS = [ { @@ -37,10 +39,34 @@ const API_KEY_FIELDS = [ label: "OpenAI API Key", placeholder: "sk-…", }, + { + provider: "concentrate", + label: "Concentrate API Key", + placeholder: "sk-cn-…", + description: + "Optional. When set, Mike routes Zero Data Retention (ZDR) tagged models through Concentrate so prompts and outputs are never stored or used for training. Models not yet in the ZDR catalog fall through to your direct provider key and may be used by the provider for training.", + }, ] as const; export default function ModelsAndApiKeysPage() { const { profile, updateModelPreference, updateApiKey } = useUserProfile(); + const [refreshing, setRefreshing] = useState(false); + const [refreshed, setRefreshed] = useState(false); + + const hasAnyKey = Object.values(profile?.apiKeys ?? {}).some( + (k) => k.configured, + ); + + const handleRefresh = () => { + setRefreshing(true); + clearProviderModelsCache(); + clearConcentrateModelsCache(); + setTimeout(() => { + setRefreshing(false); + setRefreshed(true); + setTimeout(() => setRefreshed(false), 2000); + }, 400); + }; return (
@@ -71,6 +97,24 @@ export default function ModelsAndApiKeysPage() { } />
+ {hasAnyKey && ( +
+ + + Model lists update automatically when you open the picker. + +
+ )} @@ -96,6 +140,11 @@ export default function ModelsAndApiKeysPage() { key={field.provider} label={field.label} placeholder={field.placeholder} + description={ + "description" in field + ? field.description + : undefined + } hasSavedKey={ !!profile?.apiKeys[field.provider].configured } @@ -183,7 +232,7 @@ function TabularModelDropdown({ className="cursor-pointer" onSelect={() => onChange(m.id)} title={ - !available + !available && provider ? `Add a ${providerLabel(provider)} API key to use this model` : undefined } @@ -213,6 +262,7 @@ function TabularModelDropdown({ function ApiKeyField({ label, placeholder, + description, hasSavedKey, isServerConfigured, onSave, @@ -220,6 +270,7 @@ function ApiKeyField({ }: { label: string; placeholder: string; + description?: string; hasSavedKey: boolean; isServerConfigured: boolean; onSave: (value: string) => Promise; @@ -259,6 +310,9 @@ function ApiKeyField({ return (
+ {description && ( +

{description}

+ )} {isServerConfigured && (

diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index f1710d822..3995707c8 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { ChevronDown, Check, AlertCircle } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { ChevronDown, AlertCircle, Check, Shield } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -12,14 +12,38 @@ import { } from "@/components/ui/dropdown-menu"; import { isModelAvailable } from "@/app/lib/modelAvailability"; import type { ApiKeyState } from "@/app/lib/mikeApi"; +import { + getConcentrateModels, + type ConcentrateModel, +} from "@/app/lib/concentrateModels"; +import { + getProviderModels, + clearProviderModelsCache, + type ProviderId, + type ProviderModel, +} from "@/app/lib/providerModels"; export interface ModelOption { id: string; label: string; - group: "Anthropic" | "Google" | "OpenAI"; + group: string; + zdr?: boolean; + /** + * Whether the model is only routable via Concentrate. Used by the + * availability check so the picker can mark Concentrate-only models + * unavailable when no Concentrate key is configured. + */ + concentrateOnly?: boolean; } -export const MODELS: ModelOption[] = [ +/** + * Fallback list shown when a user has no API keys configured at all. + * Once any direct or Concentrate key is added, the picker switches to + * the union of the live provider catalogs and the list below is no + * longer used. Kept short and conservative — just one well-known model + * per provider so the picker isn't empty on first visit. + */ +const STATIC_FALLBACK: ModelOption[] = [ { id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" }, @@ -28,11 +52,111 @@ export const MODELS: ModelOption[] = [ { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" }, ]; +/** + * Stable export consumed by routing-layer code (modelAvailability.ts and + * any callers that need a known-at-build-time set of model IDs). The + * picker UI itself does NOT use this — it fetches live catalogs. + */ +export const MODELS: ModelOption[] = STATIC_FALLBACK; export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; +export const ALLOWED_MODEL_IDS = new Set(STATIC_FALLBACK.map((m) => m.id)); + +const STATIC_GROUP_ORDER = ["Anthropic", "Google", "OpenAI"]; + +type ProviderCatalogs = { + anthropic: ProviderModel[]; + openai: ProviderModel[]; + google: ProviderModel[]; +}; + +function emptyCatalogs(): ProviderCatalogs { + return { anthropic: [], openai: [], google: [] }; +} -export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); +function isChatCapable(m: { capabilities?: { chat?: boolean } }): boolean { + return m.capabilities?.chat === true; +} + +function mergeAll( + direct: ProviderCatalogs, + concentrate: ConcentrateModel[], + hasConcentrateKey: boolean, +): ModelOption[] { + // Capability gate — only show models the backend has confirmed are + // chat-capable. Anything with capabilities.chat !== true is hidden, + // by design defaulting to hidden so a new modality (image, audio, + // embedding, etc.) shipped by any provider stays out of the picker + // until somebody updates the capability mapping. + const directAll: ProviderModel[] = [ + ...direct.anthropic, + ...direct.google, + ...direct.openai, + ].filter(isChatCapable); + + const out: ModelOption[] = directAll.map((m) => ({ + id: m.id, + label: m.label, + group: m.group, + zdr: m.zdr, + })); + const byId = new Map(out.map((m) => [m.id, m])); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"]; + // Overlay Concentrate's ZDR flag on any model we already have from a + // direct catalog so the shield icon appears next to direct-keyed entries. + // Add Concentrate-only chat-capable models to the bottom of their group. + if (hasConcentrateKey) { + for (const m of concentrate) { + if (!m.id || !isChatCapable(m)) continue; + const existing = byId.get(m.id); + if (existing) { + existing.zdr = m.zdr; + continue; + } + const opt: ModelOption = { + id: m.id, + label: m.label || m.id, + group: m.group, + zdr: m.zdr, + concentrateOnly: true, + }; + out.push(opt); + byId.set(m.id, opt); + } + } + + if (out.length === 0) return STATIC_FALLBACK; + return out; +} + +function labelFromId(id: string): string { + if (!id) return "Model"; + const s = STATIC_FALLBACK.find((m) => m.id === id); + if (s) return s.label; + return id + .replace(/^claude-/, "Claude ") + .replace(/^gemini-/, "Gemini ") + .replace(/^gpt-/, "GPT-") + .replace(/-/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function groupOrder(models: ModelOption[]): string[] { + const seen = new Set(); + const order: string[] = []; + for (const g of STATIC_GROUP_ORDER) { + if (models.some((m) => m.group === g)) { + order.push(g); + seen.add(g); + } + } + for (const m of models) { + if (!seen.has(m.group)) { + order.push(m.group); + seen.add(m.group); + } + } + return order; +} interface Props { value: string; @@ -42,12 +166,70 @@ interface Props { export function ModelToggle({ value, onChange, apiKeys }: Props) { const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); - const selectedLabel = selected?.label ?? "Model"; + const [direct, setDirect] = useState(emptyCatalogs); + const [concentrate, setConcentrate] = useState([]); + + const hasClaude = !!apiKeys?.claude?.configured; + const hasGemini = !!apiKeys?.gemini?.configured; + const hasOpenAI = !!apiKeys?.openai?.configured; + const hasConcentrateKey = !!apiKeys?.concentrate?.configured; + + // When a key is added or removed, the cache for that provider may hold a + // stale result (empty list when key was absent, or old list after removal). + // Clear it so the next open fetches fresh. + useEffect(() => { clearProviderModelsCache("anthropic"); }, [hasClaude]); + useEffect(() => { clearProviderModelsCache("google"); }, [hasGemini]); + useEffect(() => { clearProviderModelsCache("openai"); }, [hasOpenAI]); + + // Fetch catalogs on first open (and re-open after cache expires). Firing + // on open instead of mount means no background network calls until the + // user actually wants to pick a model. The 5-minute in-memory cache in + // getProviderModels/getConcentrateModels makes repeat opens instant. + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + const load = async ( + provider: ProviderId, + has: boolean, + slot: keyof ProviderCatalogs, + ) => { + if (!has) { + if (!cancelled) setDirect((prev) => ({ ...prev, [slot]: [] })); + return; + } + const models = await getProviderModels(provider); + if (!cancelled) setDirect((prev) => ({ ...prev, [slot]: models })); + }; + load("anthropic", hasClaude, "anthropic"); + load("openai", hasOpenAI, "openai"); + load("google", hasGemini, "google"); + if (!hasConcentrateKey) { + if (!cancelled) setConcentrate([]); + } else { + getConcentrateModels().then((m) => { + if (!cancelled) setConcentrate(m); + }); + } + return () => { + cancelled = true; + }; + }, [isOpen, hasClaude, hasGemini, hasOpenAI, hasConcentrateKey]); + + const merged = useMemo( + () => mergeAll(direct, concentrate, hasConcentrateKey), + [direct, concentrate, hasConcentrateKey], + ); + + const selected = merged.find((m) => m.id === value); + const selectedLabel = selected?.label ?? labelFromId(value); const selectedAvailable = apiKeys - ? isModelAvailable(value, apiKeys) + ? selected?.concentrateOnly + ? hasConcentrateKey + : isModelAvailable(value, apiKeys) : true; + const order = groupOrder(merged); + return ( @@ -69,39 +251,59 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { /> - - {GROUP_ORDER.map((group, gi) => { - const items = MODELS.filter((m) => m.group === group); + + {order.map((group, gi) => { + const items = merged.filter((m) => m.group === group); if (items.length === 0) return null; return (

{gi > 0 && } - + {group} {items.map((m) => { const available = apiKeys - ? isModelAvailable(m.id, apiKeys) + ? m.concentrateOnly + ? hasConcentrateKey + : isModelAvailable(m.id, apiKeys) : true; + const isSelected = m.id === value; return ( onChange(m.id)} > {m.label} {!available && ( )} - {m.id === value && available && ( - + {isSelected && available && ( + + )} + {m.zdr && ( + + + ZDR + )} ); diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 93a637fcb..20a654769 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -1,26 +1,37 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { ALLOWED_MODEL_IDS, DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; +import { useCallback, useState } from "react"; +import { DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; const STORAGE_KEY = "mike.selectedModel"; +// Light shape sanity check — accept any plausible model ID. We can't use a +// static allowlist anymore because the picker pulls live catalogs from each +// provider (Anthropic, Google, OpenAI, Concentrate), so the set of valid +// IDs grows whenever a provider ships a new model. +function looksLikeModelId(value: string): boolean { + if (!value || value.length > 200) return false; + // Block obvious garbage; allow letters, digits, dot, dash, slash, colon. + return /^[A-Za-z0-9./:_-]+$/.test(value); +} + function readStored(): string { if (typeof window === "undefined") return DEFAULT_MODEL_ID; const raw = window.localStorage.getItem(STORAGE_KEY); - if (raw && ALLOWED_MODEL_IDS.has(raw)) return raw; + if (raw && looksLikeModelId(raw)) return raw; return DEFAULT_MODEL_ID; } export function useSelectedModel(): [string, (id: string) => void] { - const [model, setModelState] = useState(DEFAULT_MODEL_ID); - - useEffect(() => { - setModelState(readStored()); - }, []); + // Initialise directly from localStorage so the label is correct on the + // very first render. Using a lazy initialiser avoids the SSR mismatch + // that a useEffect-based approach creates: the effect fires after first + // paint and sets a stored model ID that may not be in STATIC_FALLBACK, + // causing the picker to show "Model" until the catalog loads. + const [model, setModelState] = useState(() => readStored()); const setModel = useCallback((id: string) => { - const next = ALLOWED_MODEL_IDS.has(id) ? id : DEFAULT_MODEL_ID; + const next = looksLikeModelId(id) ? id : DEFAULT_MODEL_ID; setModelState(next); if (typeof window !== "undefined") { window.localStorage.setItem(STORAGE_KEY, next); diff --git a/frontend/src/app/lib/catalogTypes.ts b/frontend/src/app/lib/catalogTypes.ts new file mode 100644 index 000000000..a46c32152 --- /dev/null +++ b/frontend/src/app/lib/catalogTypes.ts @@ -0,0 +1,22 @@ +/** + * Shared catalog types — mirror of backend/src/lib/llm/catalogTypes.ts. + * Keep these two files in sync; if they drift, the picker will silently + * mis-render models because TypeScript can't cross-check the wire shape. + */ + +export type ModelCapabilities = { + /** Text in -> text out. The minimum bar for the chat picker. */ + chat: boolean; + /** Supports function/tool calling. */ + tools: boolean; + /** Supports server-sent event streaming. */ + streaming: boolean; +}; + +export type CatalogModel = { + id: string; + label: string; + group: string; + zdr?: boolean; + capabilities: ModelCapabilities; +}; diff --git a/frontend/src/app/lib/concentrateModels.ts b/frontend/src/app/lib/concentrateModels.ts new file mode 100644 index 000000000..9711bbe76 --- /dev/null +++ b/frontend/src/app/lib/concentrateModels.ts @@ -0,0 +1,36 @@ +import { apiRequest } from "./mikeApi"; +import type { CatalogModel } from "./catalogTypes"; + +/** + * Re-export under the old name for callers that imported ConcentrateModel + * directly. The wire shape is now the same unified CatalogModel everywhere. + */ +export type ConcentrateModel = CatalogModel; + +let cache: { models: CatalogModel[]; fetchedAt: number } | null = null; +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Fetch the user's authorized Concentrate model catalog. + * Returns [] when no Concentrate key is configured (the backend returns + * an empty list rather than erroring in that case). + */ +export async function getConcentrateModels(): Promise { + if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { + return cache.models; + } + try { + const json = await apiRequest<{ models?: CatalogModel[] }>( + "/concentrate/models", + ); + const models = json.models ?? []; + cache = { models, fetchedAt: Date.now() }; + return models; + } catch { + return cache?.models ?? []; + } +} + +export function clearConcentrateModelsCache(): void { + cache = null; +} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 5b7e37ef5..f944f2574 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -45,7 +45,7 @@ async function getAuthHeader(): Promise> { return { Authorization: `Bearer ${session.access_token}` }; } -async function apiRequest(path: string, init?: RequestInit): Promise { +export async function apiRequest(path: string, init?: RequestInit): Promise { const authHeaders = await getAuthHeader(); const { headers: initHeaders, ...restInit } = init ?? {}; const response = await fetch(`${API_BASE}${path}`, { @@ -124,7 +124,7 @@ export async function updateUserProfile(payload: { }); } -export type ApiKeyProvider = "claude" | "gemini" | "openai"; +export type ApiKeyProvider = "claude" | "gemini" | "openai" | "concentrate"; export type ApiKeySource = "user" | "env" | null; export type ApiKeyState = Record< ApiKeyProvider, diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index fe41f463e..b1aa06ca0 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -3,10 +3,25 @@ import type { ApiKeyState } from "@/app/lib/mikeApi"; export type ModelProvider = "claude" | "gemini" | "openai"; +// Infer the native provider by model ID prefix. Mirrors the backend's +// providerForModel(). Returns null only for IDs that don't match any +// known author prefix (those are Concentrate-only). +function inferProviderFromId(modelId: string): ModelProvider | null { + if (modelId.startsWith("claude")) return "claude"; + if (modelId.startsWith("gemini")) return "gemini"; + if (modelId.startsWith("gpt-")) return "openai"; + if (/^o[1-9]/.test(modelId)) return "openai"; + if (modelId.endsWith("-chat-latest") || modelId === "chat-latest") return "openai"; + return null; +} + export function getModelProvider(modelId: string): ModelProvider | null { const model = MODELS.find((m) => m.id === modelId); - if (!model) return null; - return modelGroupToProvider(model.group); + if (model) { + const fromGroup = modelGroupToProvider(model.group); + if (fromGroup) return fromGroup; + } + return inferProviderFromId(modelId); } export function isModelAvailable( @@ -14,8 +29,12 @@ export function isModelAvailable( apiKeys: ApiKeyState, ): boolean { const provider = getModelProvider(modelId); - if (!provider) return false; - return isProviderAvailable(provider, apiKeys); + // Unknown prefix — only Concentrate can dispatch this model. + if (!provider) return !!apiKeys.concentrate?.configured; + if (isProviderAvailable(provider, apiKeys)) return true; + // Concentrate acts as a universal fallback router — if the user has a + // Concentrate key, any known model can be routed through it. + return !!apiKeys.concentrate?.configured; } export function isProviderAvailable( @@ -33,8 +52,10 @@ export function providerLabel(provider: ModelProvider): string { export function modelGroupToProvider( group: ModelOption["group"], -): ModelProvider { +): ModelProvider | null { if (group === "Anthropic") return "claude"; if (group === "OpenAI") return "openai"; - return "gemini"; + if (group === "Google") return "gemini"; + // Concentrate-only groups (Meta, Mistral, etc.) have no native provider. + return null; } diff --git a/frontend/src/app/lib/providerModels.ts b/frontend/src/app/lib/providerModels.ts new file mode 100644 index 000000000..dce7780a9 --- /dev/null +++ b/frontend/src/app/lib/providerModels.ts @@ -0,0 +1,37 @@ +import { apiRequest } from "./mikeApi"; +import type { CatalogModel } from "./catalogTypes"; + +export type ProviderId = "anthropic" | "openai" | "google"; + +/** + * Re-export under the old name for callers that imported ProviderModel + * directly. The wire shape is now the same unified CatalogModel everywhere. + */ +export type ProviderModel = CatalogModel; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +export async function getProviderModels( + provider: ProviderId, +): Promise { + const hit = cache.get(provider); + if (hit && Date.now() - hit.fetchedAt < CACHE_TTL_MS) { + return hit.models; + } + try { + const json = await apiRequest<{ models?: CatalogModel[] }>( + `/providers/${provider}/models`, + ); + const models = json.models ?? []; + cache.set(provider, { models, fetchedAt: Date.now() }); + return models; + } catch { + return hit?.models ?? []; + } +} + +export function clearProviderModelsCache(provider?: ProviderId): void { + if (provider) cache.delete(provider); + else cache.clear(); +} diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index cb166ca61..d97b98412 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -50,13 +50,14 @@ const UserProfileContext = createContext( undefined, ); -const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"]; +const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai", "concentrate"]; function emptyApiKeys(): ApiKeyState { return { claude: { configured: false, source: null }, gemini: { configured: false, source: null }, openai: { configured: false, source: null }, + concentrate: { configured: false, source: null }, }; }