From e44a4fcaa3bbf77d053517aabf832a1764448473 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Sat, 30 May 2026 18:32:45 +0000 Subject: [PATCH 01/17] chore: add pre-push hooks for security audit and type checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .githooks/ with a pre-push hook that runs on every push: - npm audit (backend + frontend) — blocks on critical/high vulnerabilities. A 24-hour grace period prevents supply-chain attacks where a malicious "fix" advisory pressures rapid upgrades. Known transitive vulnerabilities with no upstream fix are documented in audit-known.json with expiration dates so they are revisited rather than forgotten. - TypeScript type checking (backend + frontend) — blocks on type errors. - ESLint (frontend) — advisory only; warns but does not block until existing upstream lint issues are resolved. Enable with: git config core.hooksPath .githooks Co-Authored-By: Claude Sonnet 4.6 --- .githooks/audit-grace.cjs | 231 +++++++++++++++++++++++++++++++++++++ .githooks/audit-known.json | 30 +++++ .githooks/pre-push | 84 ++++++++++++++ .gitignore | 3 + README.md | 15 +++ 5 files changed, 363 insertions(+) create mode 100755 .githooks/audit-grace.cjs create mode 100644 .githooks/audit-known.json create mode 100755 .githooks/pre-push 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 From f0a09ff3b84ecfb9ebd0e9761380e01a3ef8d9ff Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Sat, 30 May 2026 18:33:12 +0000 Subject: [PATCH 02/17] fix: replace fabricated model IDs with real API slugs The Gemini and OpenAI model IDs were speculative version numbers that do not exist as real API slugs (gemini-3.1-pro-preview, gpt-5.5, etc.). Replace with current production slugs verified against each provider's API documentation: - gemini-2.5-pro, gemini-2.0-flash, gemini-2.0-flash-lite (Google) - gpt-4o, gpt-4o-mini (OpenAI) Also update the user_profiles.tabular_model default in schema.sql from the same fabricated 'gemini-3-flash-preview' to 'gemini-2.0-flash' so fresh installs do not write an invalid model slug into new profiles. Claude slugs were already correct and are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- backend/schema.sql | 2 +- backend/src/lib/llm/models.ts | 20 +++++++++---------- .../src/app/(pages)/account/models/page.tsx | 2 +- .../app/components/assistant/ModelToggle.tsx | 10 +++++----- .../app/components/tabular/TRChatPanel.tsx | 2 +- .../components/tabular/TabularReviewView.tsx | 2 +- frontend/src/contexts/UserProfileContext.tsx | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index b6a4e934a..945a09e6c 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -17,7 +17,7 @@ create table if not exists public.user_profiles ( tier text not null default 'Free', message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), - tabular_model text not null default 'gemini-3-flash-preview', + tabular_model text not null default 'gemini-2.0-flash', created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index ed4872eff..76c86ec81 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -6,25 +6,25 @@ import type { Provider } from "./types"; // Main-chat tier (top-end) — user picks one of these per message. export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const; export const GEMINI_MAIN_MODELS = [ - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.0-flash", ] as const; -export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4-mini"] as const; +export const OPENAI_MAIN_MODELS = ["gpt-4o", "gpt-4o-mini"] as const; // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; -export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; -export const OPENAI_MID_MODELS = ["gpt-5.4-mini"] as const; +export const GEMINI_MID_MODELS = ["gemini-2.0-flash"] as const; +export const OPENAI_MID_MODELS = ["gpt-4o-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; -export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; -export const OPENAI_LOW_MODELS = ["gpt-5.4-nano"] as const; +export const GEMINI_LOW_MODELS = ["gemini-2.0-flash-lite"] as const; +export const OPENAI_LOW_MODELS = ["gpt-4o-mini"] as const; -export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; -export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; -export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview"; +export const DEFAULT_MAIN_MODEL = "gemini-2.0-flash"; +export const DEFAULT_TITLE_MODEL = "gemini-2.0-flash-lite"; +export const DEFAULT_TABULAR_MODEL = "gemini-2.0-flash"; const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index c83d68144..3ac1a9db1 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -63,7 +63,7 @@ export default function ModelsAndApiKeysPage() { diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index f1710d822..07efc677b 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -22,13 +22,13 @@ export interface ModelOption { export const MODELS: 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" }, - { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, - { id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" }, - { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", group: "Google" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash", group: "Google" }, + { id: "gpt-4o", label: "GPT-4o", group: "OpenAI" }, + { id: "gpt-4o-mini", label: "GPT-4o Mini", group: "OpenAI" }, ]; -export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; +export const DEFAULT_MODEL_ID = "gemini-2.0-flash"; export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index e066486b9..097168052 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -644,7 +644,7 @@ export function TRChatPanel({ }: Props) { const { profile, updateModelPreference } = useUserProfile(); const apiKeys = profile?.apiKeys; - const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; + const currentModel = profile?.tabularModel ?? "gemini-2.0-flash"; const [apiKeyModalProvider, setApiKeyModalProvider] = useState(null); const [chats, setChats] = useState([]); diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index cd59d1211..9fb717d02 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -93,7 +93,7 @@ export function TRView({ reviewId, projectId }: Props) { const router = useRouter(); const { profile } = useUserProfile(); const apiKeys = profile?.apiKeys; - const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview"; + const tabularModel = profile?.tabularModel ?? "gemini-2.0-flash"; useEffect(() => { const params = new URLSearchParams(window.location.search); diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index cb166ca61..750478c1d 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -100,7 +100,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { creditsResetDate: futureResetDate.toISOString(), creditsRemaining: 999999, // temporarily unlimited tier: "Free", - tabularModel: "gemini-3-flash-preview", + tabularModel: "gemini-2.0-flash", apiKeys: emptyApiKeys(), }); } finally { From 25011a229e0919a33e402cf0b64823034792e6b8 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Sat, 30 May 2026 19:02:19 +0000 Subject: [PATCH 03/17] feat: add Concentrate AI as an optional universal model router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concentrate exposes an OpenAI-Responses-compatible endpoint that proxies to many underlying model authors (Anthropic, Google, OpenAI, …) behind a single API key. This commit adds it as a fourth, optional provider. How it routes (in order): 1. Dynamic model IDs (not in the static set) → Concentrate. 2. Static model with a direct provider key configured → use that key directly. A Concentrate key never silently overrides a configured direct key. 3. Static model with no direct key but a Concentrate key configured → Concentrate acts as a fallback universal router. 4. No key at all → routed to the native provider so it surfaces a clear "key not configured" error. This means a user with both an Anthropic key and a Concentrate key continues to use Anthropic directly for Claude models — Concentrate only takes over when there is no direct key. Backend - backend/src/lib/llm/concentrate.ts: OpenAI-Responses adapter pointed at https://api.concentrate.ai/v1/responses (overridable via CONCENTRATE_RESPONSES_URL). After a tool turn the request loop now appends both the assistant function_call items and the function_call_output items so the model sees a complete tool round. - backend/src/lib/llm/index.ts: new pick() encodes the routing rules. - backend/src/lib/llm/types.ts: extend Provider + UserApiKeys with "concentrate". - backend/src/lib/llm/models.ts: providerForModel() returns "concentrate" for unknown slugs instead of throwing; isStaticModel() helper added. - backend/src/lib/userApiKeys.ts, userSettings.ts: support concentrate as a valid provider for the encrypted key store and the resolved title-model fallback. - backend/src/routes/concentrateModels.ts: GET /concentrate/models returns the authorized model catalog from Concentrate's /v1/models with a 5-minute in-process cache. - backend/schema.sql: extend user_api_keys provider CHECK to include 'concentrate'. - backend/.env.example: add CONCENTRATE_API_KEY and the optional CONCENTRATE_RESPONSES_URL override. Frontend - frontend/src/app/lib/mikeApi.ts: extend ApiKeyProvider union; export apiRequest so the new lib can reuse the auth wrapper. - frontend/src/app/lib/concentrateModels.ts: client for /concentrate/models with a matching 5-minute cache. - frontend/src/app/lib/modelAvailability.ts: a Concentrate key makes any static model "available" (since Concentrate can route it). - frontend/src/app/(pages)/account/models/page.tsx: render the Concentrate API-key field at the bottom of the API-keys list with a short description of what it does. - frontend/src/contexts/UserProfileContext.tsx: include concentrate in the empty-key initializer and the provider iteration list. The model picker itself is unchanged in this commit — Concentrate shows up purely as a key field and a routing fallback. Surfacing the dynamic Concentrate catalog in the picker is a follow-up. Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.example | 7 + backend/schema.sql | 2 +- backend/src/index.ts | 2 + backend/src/lib/llm/concentrate.ts | 331 ++++++++++++++++++ backend/src/lib/llm/index.ts | 59 +++- backend/src/lib/llm/models.ts | 8 +- backend/src/lib/llm/types.ts | 3 +- backend/src/lib/userApiKeys.ts | 10 +- backend/src/lib/userSettings.ts | 2 + backend/src/routes/concentrateModels.ts | 98 ++++++ .../src/app/(pages)/account/models/page.tsx | 19 +- frontend/src/app/lib/concentrateModels.ts | 35 ++ frontend/src/app/lib/mikeApi.ts | 4 +- frontend/src/app/lib/modelAvailability.ts | 5 +- frontend/src/contexts/UserProfileContext.tsx | 3 +- 15 files changed, 569 insertions(+), 19 deletions(-) create mode 100644 backend/src/lib/llm/concentrate.ts create mode 100644 backend/src/routes/concentrateModels.ts create mode 100644 frontend/src/app/lib/concentrateModels.ts diff --git a/backend/.env.example b/backend/.env.example index 6b4d56150..d6541f808 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,5 +16,12 @@ 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 + +# Optional: override the Concentrate Responses API base URL. Useful when +# pointing at a staging tier or a self-hosted Concentrate instance. The +# /v1/models catalog endpoint is derived from the same base by replacing +# the trailing /responses with /models, so a single override switches both. +# CONCENTRATE_RESPONSES_URL=https://api.concentrate.ai/v1/responses diff --git a/backend/schema.sql b/backend/schema.sql index 945a09e6c..d7fc23d39 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..f9fea4e16 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ 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"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -118,6 +119,7 @@ app.use("/workflows", workflowsRouter); app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); +app.use("/concentrate/models", concentrateModelsRouter); app.get("/health", (_req, res) => res.json({ ok: true })); 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/index.ts b/backend/src/lib/llm/index.ts index 4b5e97936..2d5f3e544 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,19 +1,58 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; import { streamOpenAI, completeOpenAIText } from "./openai"; -import { providerForModel } from "./models"; +import { streamConcentrate, completeConcentrateText } from "./concentrate"; +import { providerForModel, isStaticModel } from "./models"; 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. If the model ID is not in the static set it is a dynamic/Concentrate + * model — route to Concentrate regardless of other keys. + * 2. If the user has a direct key for the inferred native provider, use it. + * 3. If the user has a Concentrate key, fall back to Concentrate (acts as a + * universal router for any model the user hasn't configured directly). + * 4. No key found — let the provider throw a missing-key error. + * + * A Concentrate key never silently overrides a configured direct provider key. + */ +function pick( + model: string, + apiKeys: UserApiKeys | undefined, +): { provider: "claude" | "gemini" | "openai" | "concentrate"; slug: string } { + // Dynamic model ID — only Concentrate can handle it. + if (!isStaticModel(model)) { + return { provider: "concentrate", slug: model }; + } + + const native = providerForModel(model); + + // User has a direct key for this provider — use it. + 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 }; + + // No direct key — fall back to Concentrate if available. + if (apiKeys?.concentrate) return { provider: "concentrate", slug: model }; + + // No key at all — route to native provider so it throws a clear error. + return { provider: native as "claude" | "gemini" | "openai", 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 +62,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 76c86ec81..6134746c9 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -42,11 +42,17 @@ const ALL_MODELS = new Set([ // Provider inference // --------------------------------------------------------------------------- +// Returns the native provider for a static model ID, or "concentrate" for +// dynamic IDs that are not in the known static set. 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}`); + return "concentrate"; +} + +export function isStaticModel(id: string): boolean { + return ALL_MODELS.has(id); } export function resolveModel(id: string | null | undefined, fallback: string): string { 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..2b700df87 --- /dev/null +++ b/backend/src/routes/concentrateModels.ts @@ -0,0 +1,98 @@ +/** + * 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 since the catalog rarely changes within a + * single session and the Concentrate /v1/models endpoint is the bottleneck + * for the model picker UX. + */ +import { Router, type Request, type Response } from "express"; +import { requireAuth } from "../middleware/auth"; +import { getUserApiKeys } from "../lib/userSettings"; + +export const concentrateModelsRouter = Router(); + +const DEFAULT_MODELS_URL = "https://api.concentrate.ai/v1/models"; + +function modelsUrl(): string { + // Derive /v1/models from CONCENTRATE_RESPONSES_URL when set so one env var + // switches both the chat endpoint and the catalog endpoint together. + const responses = process.env.CONCENTRATE_RESPONSES_URL?.trim(); + if (responses) return responses.replace(/\/responses$/, "/models"); + return DEFAULT_MODELS_URL; +} + +type ConcentrateModel = { + id: string; + name: string; + author: string; +}; + +type CacheEntry = { + models: ConcentrateModel[]; + 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() || ""; +} + +type RawModel = { + slug?: string; + name?: string; + author?: { slug?: string }; +}; + +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 models = Array.isArray(json) + ? json + : ((json as { data?: unknown[] }).data ?? []); + return (models as RawModel[]).map((m) => ({ + id: m.slug ?? "", + name: m.name ?? m.slug ?? "", + author: m.author?.slug ?? "unknown", + })); +} + +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() }; + res.json({ models }); + } catch (err) { + console.error("[concentrate-models]", err); + res.json({ models: cache?.models ?? [] }); + } + }, +); diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 3ac1a9db1..5c9c359e2 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -37,6 +37,13 @@ const API_KEY_FIELDS = [ label: "OpenAI API Key", placeholder: "sk-…", }, + { + provider: "concentrate", + label: "Concentrate API Key", + placeholder: "sk-cn-…", + description: + "Optional. Concentrate routes any model you don't have a direct key for through a single endpoint — useful for trying models without managing one key per provider.", + }, ] as const; export default function ModelsAndApiKeysPage() { @@ -96,6 +103,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 } @@ -213,6 +225,7 @@ function TabularModelDropdown({ function ApiKeyField({ label, placeholder, + description, hasSavedKey, isServerConfigured, onSave, @@ -220,6 +233,7 @@ function ApiKeyField({ }: { label: string; placeholder: string; + description?: string; hasSavedKey: boolean; isServerConfigured: boolean; onSave: (value: string) => Promise; @@ -258,7 +272,10 @@ function ApiKeyField({ return (
- + + {description && ( +

{description}

+ )} {isServerConfigured && (

diff --git a/frontend/src/app/lib/concentrateModels.ts b/frontend/src/app/lib/concentrateModels.ts new file mode 100644 index 000000000..c22aff7a1 --- /dev/null +++ b/frontend/src/app/lib/concentrateModels.ts @@ -0,0 +1,35 @@ +import { apiRequest } from "./mikeApi"; + +export type ConcentrateModel = { + id: string; + name: string; + author: string; +}; + +let cache: { models: ConcentrateModel[]; 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?: ConcentrateModel[] }>( + "/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..89a67bd6c 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -15,7 +15,10 @@ export function isModelAvailable( ): boolean { const provider = getModelProvider(modelId); if (!provider) return false; - return isProviderAvailable(provider, apiKeys); + 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( diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index 750478c1d..4ae8eed5e 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 }, }; } From cc660b9cdd8cf2f1bb878950cfe5d2d081078037 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Sat, 30 May 2026 19:15:08 +0000 Subject: [PATCH 04/17] feat: ZDR-only Concentrate catalog + dynamic models in picker When a Concentrate key is configured, the model picker now lists the authorized Concentrate catalog inline alongside the static Anthropic / Google / OpenAI models, grouped by model author. A small shield icon marks Zero Data Retention models so users can pick a ZDR model at a glance. The Concentrate catalog is filtered to ZDR-only models server-side: backend/src/routes/concentrateModels.ts checks each model's providers map for at least one zdr-certified backend and drops everything else. This is enforced at the source so a misbehaving client cannot pull non-ZDR slugs by skipping a UI filter. Users who want a non-ZDR model should configure that provider's direct key rather than rely on the Concentrate router. Backend - backend/src/routes/concentrateModels.ts: add zdr:boolean to the returned model shape; filter the API response so only models with at least one ZDR-advertising Concentrate-side provider are included. Frontend - frontend/src/app/lib/concentrateModels.ts: extend the ConcentrateModel type with a zdr boolean. - frontend/src/app/components/assistant/ModelToggle.tsx: relax the group field to string, fetch Concentrate models on Concentrate-key change, merge them into the picker grouped by author, render a shield icon next to ZDR models. The static MODELS export and ALLOWED_MODEL_IDS remain stable so routing-layer callers are unaffected. - frontend/src/app/lib/modelAvailability.ts: modelGroupToProvider now returns null for non-static groups; isModelAvailable treats unknown model IDs as available iff a Concentrate key is configured. - frontend/src/app/(pages)/account/models/page.tsx: defensively skip the "Add an X API key" tooltip when the model has no native provider. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/concentrateModels.ts | 34 ++++- .../src/app/(pages)/account/models/page.tsx | 2 +- .../app/components/assistant/ModelToggle.tsx | 133 ++++++++++++++++-- frontend/src/app/lib/concentrateModels.ts | 1 + frontend/src/app/lib/modelAvailability.ts | 9 +- 5 files changed, 155 insertions(+), 24 deletions(-) diff --git a/backend/src/routes/concentrateModels.ts b/backend/src/routes/concentrateModels.ts index 2b700df87..6fb659536 100644 --- a/backend/src/routes/concentrateModels.ts +++ b/backend/src/routes/concentrateModels.ts @@ -27,6 +27,13 @@ type ConcentrateModel = { id: string; name: string; author: string; + /** + * Whether at least one Concentrate-side provider for this model + * advertises Zero Data Retention. The catalog is filtered to ZDR-only + * models server-side; this flag is included on every returned model + * so the UI can display a ZDR badge without an extra lookup. + */ + zdr: boolean; }; type CacheEntry = { @@ -41,12 +48,22 @@ function resolveKey(userKey?: string | null): string { return userKey?.trim() || process.env.CONCENTRATE_API_KEY?.trim() || ""; } +type RawProvider = { + zdr?: false | { policy_url?: string; certificate_url?: string }; +}; type RawModel = { slug?: string; name?: string; author?: { slug?: string }; + providers?: Record; }; +function modelHasZdr(m: RawModel): boolean { + const providers = m.providers; + if (!providers || typeof providers !== "object") return false; + return Object.values(providers).some((p) => !!p.zdr); +} + async function fetchModelsFromApi(key: string): Promise { const res = await fetch(modelsUrl(), { headers: { Authorization: `Bearer ${key}` }, @@ -61,11 +78,18 @@ async function fetchModelsFromApi(key: string): Promise { const models = Array.isArray(json) ? json : ((json as { data?: unknown[] }).data ?? []); - return (models as RawModel[]).map((m) => ({ - id: m.slug ?? "", - name: m.name ?? m.slug ?? "", - author: m.author?.slug ?? "unknown", - })); + // Server-side filter: only include models with at least one + // ZDR-certified provider behind the Concentrate router. Users who want + // to fall back to non-ZDR models should configure that provider's key + // directly rather than via Concentrate. + return (models as RawModel[]) + .filter(modelHasZdr) + .map((m) => ({ + id: m.slug ?? "", + name: m.name ?? m.slug ?? "", + author: m.author?.slug ?? "unknown", + zdr: true, + })); } concentrateModelsRouter.get( diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 5c9c359e2..f0f96ea31 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -195,7 +195,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 } diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index 07efc677b..bf6a02a36 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, Check, AlertCircle, Shield } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -12,14 +12,25 @@ 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"; 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[] = [ +const STATIC_MODELS: 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-2.5-pro", label: "Gemini 2.5 Pro", group: "Google" }, @@ -28,11 +39,63 @@ export const MODELS: ModelOption[] = [ { id: "gpt-4o-mini", label: "GPT-4o Mini", group: "OpenAI" }, ]; +/** + * Static set used by routing-layer code that needs a stable, build-time + * list of model IDs. The picker itself merges this with the live + * Concentrate catalog when a Concentrate key is configured. + */ +export const MODELS: ModelOption[] = STATIC_MODELS; export const DEFAULT_MODEL_ID = "gemini-2.0-flash"; +export const ALLOWED_MODEL_IDS = new Set(STATIC_MODELS.map((m) => m.id)); + +const STATIC_GROUP_ORDER = ["Anthropic", "Google", "OpenAI"]; + +function authorLabel(author: string): string { + if (author === "anthropic") return "Anthropic"; + if (author === "openai") return "OpenAI"; + if (author === "google") return "Google"; + return author.charAt(0).toUpperCase() + author.slice(1); +} -export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); +function mergeModels( + concentrate: ConcentrateModel[], + hasConcentrateKey: boolean, +): ModelOption[] { + if (!hasConcentrateKey || concentrate.length === 0) return STATIC_MODELS; -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"]; + const out: ModelOption[] = [...STATIC_MODELS]; + const seen = new Set(out.map((m) => m.id)); + for (const m of concentrate) { + if (!m.id || seen.has(m.id)) continue; + out.push({ + id: m.id, + label: m.name || m.id, + group: authorLabel(m.author), + zdr: m.zdr, + concentrateOnly: true, + }); + seen.add(m.id); + } + return out; +} + +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 +105,40 @@ interface Props { export function ModelToggle({ value, onChange, apiKeys }: Props) { const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); + const [concentrateModels, setConcentrateModels] = useState< + ConcentrateModel[] + >([]); + const hasConcentrateKey = !!apiKeys?.concentrate?.configured; + + useEffect(() => { + if (!hasConcentrateKey) { + setConcentrateModels([]); + return; + } + let cancelled = false; + getConcentrateModels().then((m) => { + if (!cancelled) setConcentrateModels(m); + }); + return () => { + cancelled = true; + }; + }, [hasConcentrateKey]); + + const merged = useMemo( + () => mergeModels(concentrateModels, hasConcentrateKey), + [concentrateModels, hasConcentrateKey], + ); + + const selected = merged.find((m) => m.id === value); const selectedLabel = selected?.label ?? "Model"; const selectedAvailable = apiKeys - ? isModelAvailable(value, apiKeys) + ? selected?.concentrateOnly + ? hasConcentrateKey + : isModelAvailable(value, apiKeys) : true; + const order = groupOrder(merged); + return ( @@ -69,9 +160,13 @@ 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 (

@@ -81,7 +176,9 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { {items.map((m) => { const available = apiKeys - ? isModelAvailable(m.id, apiKeys) + ? m.concentrateOnly + ? hasConcentrateKey + : isModelAvailable(m.id, apiKeys) : true; return ( onChange(m.id)} > {m.label} + {m.zdr && ( + + )} {!available && ( )} {m.id === value && available && ( - + )} ); diff --git a/frontend/src/app/lib/concentrateModels.ts b/frontend/src/app/lib/concentrateModels.ts index c22aff7a1..b55454e03 100644 --- a/frontend/src/app/lib/concentrateModels.ts +++ b/frontend/src/app/lib/concentrateModels.ts @@ -4,6 +4,7 @@ export type ConcentrateModel = { id: string; name: string; author: string; + zdr: boolean; }; let cache: { models: ConcentrateModel[]; fetchedAt: number } | null = null; diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 89a67bd6c..ccec4df4f 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -14,7 +14,8 @@ export function isModelAvailable( apiKeys: ApiKeyState, ): boolean { const provider = getModelProvider(modelId); - if (!provider) return false; + // Unknown/dynamic model IDs are only reachable through Concentrate. + 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. @@ -36,8 +37,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; } From a07662c9bfe46a4b4f5f7a5012c2c0b9edf3a19e Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Tue, 2 Jun 2026 01:52:06 +0000 Subject: [PATCH 05/17] feat: live model catalogs with ZDR-aware routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static ALLOWED_MODEL_IDS list with live catalogs fetched from each provider's /models endpoint (Anthropic, Google, OpenAI, Concentrate). Every model now carries explicit capability flags (chat, tools, streaming) that default to FALSE — the picker only surfaces models whose capabilities have been verified, so new model classes (audio, image, embedding) stay hidden until someone teaches Mike about them. When Concentrate's catalog tags a model as ZDR and the user has a Concentrate key, routing prefers Concentrate over the direct provider key so the privacy guarantee advertised by the ZDR badge is actually enforced server-side. Non-ZDR models continue to prefer the direct key, so users never lose access to brand-new frontier releases while waiting for ZDR certification. - Add /providers/:provider/models backend routes with per-provider capability derivation (Anthropic metadata, OpenAI family table, Google supportedGenerationMethods) - Rewrite /concentrate/models to return the unified ProviderCatalogModel shape with capabilities derived from Concentrate's supports payload - Add process-local ZDR set (concentrateCatalog.ts) populated when the Concentrate catalog is fetched, consulted by pick() for routing - ModelToggle picker now unions all four live catalogs filtered on capabilities.chat, with ZDR badge (gray pill + shield) on qualifying models - useSelectedModel accepts any plausible model ID shape instead of gating on a build-time allowlist - modelAvailability uses prefix-based provider inference for dynamic IDs - Bump deprecated gemini-2.0-* defaults to gemini-2.5-* throughout - Add o1/o3/o4 prefix recognition for OpenAI reasoning models Co-Authored-By: Claude Opus 4.6 --- backend/schema.sql | 2 +- backend/src/index.ts | 2 + backend/src/lib/llm/catalogTypes.ts | 57 +++ backend/src/lib/llm/concentrateCatalog.ts | 38 ++ backend/src/lib/llm/index.ts | 55 ++- backend/src/lib/llm/models.ts | 18 +- backend/src/routes/concentrateModels.ts | 181 ++++++++-- backend/src/routes/providerModels.ts | 330 ++++++++++++++++++ .../src/app/(pages)/account/models/page.tsx | 4 +- .../app/components/assistant/ModelToggle.tsx | 181 +++++++--- .../app/components/tabular/TRChatPanel.tsx | 2 +- .../components/tabular/TabularReviewView.tsx | 2 +- frontend/src/app/hooks/useSelectedModel.ts | 16 +- frontend/src/app/lib/catalogTypes.ts | 22 ++ frontend/src/app/lib/concentrateModels.ts | 18 +- frontend/src/app/lib/modelAvailability.ts | 20 +- frontend/src/app/lib/providerModels.ts | 37 ++ frontend/src/contexts/UserProfileContext.tsx | 2 +- 18 files changed, 852 insertions(+), 135 deletions(-) create mode 100644 backend/src/lib/llm/catalogTypes.ts create mode 100644 backend/src/lib/llm/concentrateCatalog.ts create mode 100644 backend/src/routes/providerModels.ts create mode 100644 frontend/src/app/lib/catalogTypes.ts create mode 100644 frontend/src/app/lib/providerModels.ts diff --git a/backend/schema.sql b/backend/schema.sql index d7fc23d39..adf8d6780 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -17,7 +17,7 @@ create table if not exists public.user_profiles ( tier text not null default 'Free', message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), - tabular_model text not null default 'gemini-2.0-flash', + tabular_model text not null default 'gemini-2.5-flash', created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/src/index.ts b/backend/src/index.ts index f9fea4e16..4b663ced3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ 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; @@ -120,6 +121,7 @@ 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/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 2d5f3e544..5c930af44 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -2,7 +2,8 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; import { streamOpenAI, completeOpenAIText } from "./openai"; import { streamConcentrate, completeConcentrateText } from "./concentrate"; -import { providerForModel, isStaticModel } from "./models"; +import { providerForModel } from "./models"; +import { isZdrViaConcentrate } from "./concentrateCatalog"; import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; export * from "./types"; @@ -12,36 +13,52 @@ export * from "./models"; * Resolve a model id to a provider. * * Routing rules (in order): - * 1. If the model ID is not in the static set it is a dynamic/Concentrate - * model — route to Concentrate regardless of other keys. - * 2. If the user has a direct key for the inferred native provider, use it. - * 3. If the user has a Concentrate key, fall back to Concentrate (acts as a - * universal router for any model the user hasn't configured directly). - * 4. No key found — let the provider throw a missing-key error. - * - * A Concentrate key never silently overrides a configured direct provider key. + * 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 } { - // Dynamic model ID — only Concentrate can handle it. - if (!isStaticModel(model)) { + const native = providerForModel(model); + + // Rule 1 — unknown prefix, only Concentrate can dispatch. + if (native === "concentrate") { return { provider: "concentrate", slug: model }; } - const native = providerForModel(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 }; + } - // User has a direct key for this provider — use it. - 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 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 }; - // No direct key — fall back to Concentrate if available. + // Rule 4 — Concentrate as a fallback router when no direct key exists. if (apiKeys?.concentrate) return { provider: "concentrate", slug: model }; - // No key at all — route to native provider so it throws a clear error. - return { provider: native as "claude" | "gemini" | "openai", slug: model }; + // Rule 5 — native provider so it surfaces a clear missing-key error. + return { provider: native, slug: model }; } export async function streamChatWithTools( diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 6134746c9..d8333f389 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -7,24 +7,24 @@ import type { Provider } from "./types"; export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const; export const GEMINI_MAIN_MODELS = [ "gemini-2.5-pro", - "gemini-2.0-flash", + "gemini-2.5-flash", ] as const; export const OPENAI_MAIN_MODELS = ["gpt-4o", "gpt-4o-mini"] as const; // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; -export const GEMINI_MID_MODELS = ["gemini-2.0-flash"] as const; +export const GEMINI_MID_MODELS = ["gemini-2.5-flash"] as const; export const OPENAI_MID_MODELS = ["gpt-4o-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; -export const GEMINI_LOW_MODELS = ["gemini-2.0-flash-lite"] as const; +export const GEMINI_LOW_MODELS = ["gemini-2.5-flash-lite"] as const; export const OPENAI_LOW_MODELS = ["gpt-4o-mini"] as const; -export const DEFAULT_MAIN_MODEL = "gemini-2.0-flash"; -export const DEFAULT_TITLE_MODEL = "gemini-2.0-flash-lite"; -export const DEFAULT_TABULAR_MODEL = "gemini-2.0-flash"; +export const DEFAULT_MAIN_MODEL = "gemini-2.5-flash"; +export const DEFAULT_TITLE_MODEL = "gemini-2.5-flash-lite"; +export const DEFAULT_TABULAR_MODEL = "gemini-2.5-flash"; const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, @@ -42,12 +42,14 @@ const ALL_MODELS = new Set([ // Provider inference // --------------------------------------------------------------------------- -// Returns the native provider for a static model ID, or "concentrate" for -// dynamic IDs that are not in the known static set. +// 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"; + if (/^o[1-9]/.test(model)) return "openai"; return "concentrate"; } diff --git a/backend/src/routes/concentrateModels.ts b/backend/src/routes/concentrateModels.ts index 6fb659536..6307e65ce 100644 --- a/backend/src/routes/concentrateModels.ts +++ b/backend/src/routes/concentrateModels.ts @@ -3,44 +3,43 @@ * * 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 since the catalog rarely changes within a - * single session and the Concentrate /v1/models endpoint is the bottleneck - * for the model picker UX. + * 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 { - // Derive /v1/models from CONCENTRATE_RESPONSES_URL when set so one env var - // switches both the chat endpoint and the catalog endpoint together. const responses = process.env.CONCENTRATE_RESPONSES_URL?.trim(); if (responses) return responses.replace(/\/responses$/, "/models"); return DEFAULT_MODELS_URL; } -type ConcentrateModel = { - id: string; - name: string; - author: string; - /** - * Whether at least one Concentrate-side provider for this model - * advertises Zero Data Retention. The catalog is filtered to ZDR-only - * models server-side; this flag is included on every returned model - * so the UI can display a ZDR badge without an extra lookup. - */ - zdr: boolean; -}; - type CacheEntry = { - models: ConcentrateModel[]; + models: ProviderCatalogModel[]; fetchedAt: number; }; - const CACHE_TTL_MS = 5 * 60 * 1000; let cache: CacheEntry | null = null; @@ -48,23 +47,116 @@ 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?: false | { policy_url?: string; certificate_url?: string }; + zdr?: ZdrInfo; + supports?: { + input?: SupportsInput; + tools?: SupportsTools; + streaming?: boolean; + }; }; + type RawModel = { slug?: string; name?: string; - author?: { slug?: string }; + author?: { slug?: string; display_name?: string }; providers?: Record; }; -function modelHasZdr(m: RawModel): boolean { - const providers = m.providers; - if (!providers || typeof providers !== "object") return false; - return Object.values(providers).some((p) => !!p.zdr); +// --------------------------------------------------------------------------- +// 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); } -async function fetchModelsFromApi(key: string): Promise { +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}` }, }); @@ -75,21 +167,28 @@ async function fetchModelsFromApi(key: string): Promise { ); } const json = await res.json(); - const models = Array.isArray(json) + const raw = Array.isArray(json) ? json : ((json as { data?: unknown[] }).data ?? []); - // Server-side filter: only include models with at least one - // ZDR-certified provider behind the Concentrate router. Users who want - // to fall back to non-ZDR models should configure that provider's key - // directly rather than via Concentrate. - return (models as RawModel[]) - .filter(modelHasZdr) + + 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 ?? "", - name: m.name ?? m.slug ?? "", - author: m.author?.slug ?? "unknown", + 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( @@ -113,6 +212,14 @@ concentrateModelsRouter.get( 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); diff --git a/backend/src/routes/providerModels.ts b/backend/src/routes/providerModels.ts new file mode 100644 index 000000000..044d34014 --- /dev/null +++ b/backend/src/routes/providerModels.ts @@ -0,0 +1,330 @@ +/** + * 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 { + return id + .replace(/^gpt-/, "GPT-") + .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; +}> = [ + // gpt-5* family — full support + { test: /^gpt-5(\.\d+)?(-(mini|nano|turbo))?(-\d+)?$/, chat: true, tools: true, streaming: true }, + // gpt-4o, gpt-4o-mini, gpt-4-turbo etc. + { test: /^gpt-4(o|\.1|\.5)?(-(mini|nano|turbo))?(-\d{4}-\d{2}-\d{2})?$/, chat: true, tools: true, streaming: true }, + // o3, o4-mini etc. — reasoning models, support tools+streaming on Responses API + { test: /^o[3-9](-(mini|preview))?$/, chat: true, tools: true, streaming: true }, + // o1, o1-mini, o1-preview — early reasoning; tools support was added later + // but is universal on current Responses API. Stream-via-Responses works. + { test: /^o1(-(mini|preview))?$/, 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.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 f0f96ea31..1091daa21 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -42,7 +42,7 @@ const API_KEY_FIELDS = [ label: "Concentrate API Key", placeholder: "sk-cn-…", description: - "Optional. Concentrate routes any model you don't have a direct key for through a single endpoint — useful for trying models without managing one key per provider.", + "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; @@ -70,7 +70,7 @@ export default function ModelsAndApiKeysPage() { diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index bf6a02a36..987b466b1 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { ChevronDown, Check, AlertCircle, Shield } from "lucide-react"; +import { ChevronDown, AlertCircle, Shield } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -16,6 +16,11 @@ import { getConcentrateModels, type ConcentrateModel, } from "@/app/lib/concentrateModels"; +import { + getProviderModels, + type ProviderId, + type ProviderModel, +} from "@/app/lib/providerModels"; export interface ModelOption { id: string; @@ -30,52 +35,92 @@ export interface ModelOption { concentrateOnly?: boolean; } -const STATIC_MODELS: ModelOption[] = [ - { id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" }, +/** + * 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-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, - { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", group: "Google" }, - { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash", group: "Google" }, - { id: "gpt-4o", label: "GPT-4o", group: "OpenAI" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash", group: "Google" }, { id: "gpt-4o-mini", label: "GPT-4o Mini", group: "OpenAI" }, ]; /** - * Static set used by routing-layer code that needs a stable, build-time - * list of model IDs. The picker itself merges this with the live - * Concentrate catalog when a Concentrate key is configured. + * 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_MODELS; -export const DEFAULT_MODEL_ID = "gemini-2.0-flash"; -export const ALLOWED_MODEL_IDS = new Set(STATIC_MODELS.map((m) => m.id)); +export const MODELS: ModelOption[] = STATIC_FALLBACK; +export const DEFAULT_MODEL_ID = "gemini-2.5-flash"; +export const ALLOWED_MODEL_IDS = new Set(STATIC_FALLBACK.map((m) => m.id)); const STATIC_GROUP_ORDER = ["Anthropic", "Google", "OpenAI"]; -function authorLabel(author: string): string { - if (author === "anthropic") return "Anthropic"; - if (author === "openai") return "OpenAI"; - if (author === "google") return "Google"; - return author.charAt(0).toUpperCase() + author.slice(1); +type ProviderCatalogs = { + anthropic: ProviderModel[]; + openai: ProviderModel[]; + google: ProviderModel[]; +}; + +function emptyCatalogs(): ProviderCatalogs { + return { anthropic: [], openai: [], google: [] }; } -function mergeModels( +function isChatCapable(m: { capabilities?: { chat?: boolean } }): boolean { + return m.capabilities?.chat === true; +} + +function mergeAll( + direct: ProviderCatalogs, concentrate: ConcentrateModel[], hasConcentrateKey: boolean, ): ModelOption[] { - if (!hasConcentrateKey || concentrate.length === 0) return STATIC_MODELS; - - const out: ModelOption[] = [...STATIC_MODELS]; - const seen = new Set(out.map((m) => m.id)); - for (const m of concentrate) { - if (!m.id || seen.has(m.id)) continue; - out.push({ - id: m.id, - label: m.name || m.id, - group: authorLabel(m.author), - zdr: m.zdr, - concentrateOnly: true, - }); - seen.add(m.id); + // 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])); + + // 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; } @@ -105,19 +150,50 @@ interface Props { export function ModelToggle({ value, onChange, apiKeys }: Props) { const [isOpen, setIsOpen] = useState(false); - const [concentrateModels, setConcentrateModels] = useState< - ConcentrateModel[] - >([]); + 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; + // Fetch each catalog the user has a key for. Independent of each other, + // each writes into its own slot of the catalogs state on resolve. + useEffect(() => { + 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"); + return () => { + cancelled = true; + }; + }, [hasClaude, hasGemini, hasOpenAI]); + useEffect(() => { if (!hasConcentrateKey) { - setConcentrateModels([]); + setConcentrate([]); return; } let cancelled = false; getConcentrateModels().then((m) => { - if (!cancelled) setConcentrateModels(m); + if (!cancelled) setConcentrate(m); }); return () => { cancelled = true; @@ -125,8 +201,8 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { }, [hasConcentrateKey]); const merged = useMemo( - () => mergeModels(concentrateModels, hasConcentrateKey), - [concentrateModels, hasConcentrateKey], + () => mergeAll(direct, concentrate, hasConcentrateKey), + [direct, concentrate, hasConcentrateKey], ); const selected = merged.find((m) => m.id === value); @@ -161,9 +237,10 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { {order.map((group, gi) => { const items = merged.filter((m) => m.group === group); @@ -171,7 +248,9 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { return (
{gi > 0 && } - + {group} {items.map((m) => { @@ -180,10 +259,11 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { ? hasConcentrateKey : isModelAvailable(m.id, apiKeys) : true; + const isSelected = m.id === value; return ( onChange(m.id)} > {m.label} - {m.zdr && ( - - )} {!available && ( )} - {m.id === value && available && ( - + {m.zdr && ( + + + ZDR + )} ); diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 097168052..4ca68e2d3 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -644,7 +644,7 @@ export function TRChatPanel({ }: Props) { const { profile, updateModelPreference } = useUserProfile(); const apiKeys = profile?.apiKeys; - const currentModel = profile?.tabularModel ?? "gemini-2.0-flash"; + const currentModel = profile?.tabularModel ?? "gemini-2.5-flash"; const [apiKeyModalProvider, setApiKeyModalProvider] = useState(null); const [chats, setChats] = useState([]); diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 9fb717d02..23d133f8c 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -93,7 +93,7 @@ export function TRView({ reviewId, projectId }: Props) { const router = useRouter(); const { profile } = useUserProfile(); const apiKeys = profile?.apiKeys; - const tabularModel = profile?.tabularModel ?? "gemini-2.0-flash"; + const tabularModel = profile?.tabularModel ?? "gemini-2.5-flash"; useEffect(() => { const params = new URLSearchParams(window.location.search); diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 93a637fcb..12d7a05c6 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -1,14 +1,24 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { ALLOWED_MODEL_IDS, DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; +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; } @@ -20,7 +30,7 @@ export function useSelectedModel(): [string, (id: string) => void] { }, []); 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 index b55454e03..9711bbe76 100644 --- a/frontend/src/app/lib/concentrateModels.ts +++ b/frontend/src/app/lib/concentrateModels.ts @@ -1,13 +1,13 @@ import { apiRequest } from "./mikeApi"; +import type { CatalogModel } from "./catalogTypes"; -export type ConcentrateModel = { - id: string; - name: string; - author: string; - zdr: boolean; -}; +/** + * 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: ConcentrateModel[]; fetchedAt: number } | null = null; +let cache: { models: CatalogModel[]; fetchedAt: number } | null = null; const CACHE_TTL_MS = 5 * 60 * 1000; /** @@ -15,12 +15,12 @@ const CACHE_TTL_MS = 5 * 60 * 1000; * Returns [] when no Concentrate key is configured (the backend returns * an empty list rather than erroring in that case). */ -export async function getConcentrateModels(): Promise { +export async function getConcentrateModels(): Promise { if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { return cache.models; } try { - const json = await apiRequest<{ models?: ConcentrateModel[] }>( + const json = await apiRequest<{ models?: CatalogModel[] }>( "/concentrate/models", ); const models = json.models ?? []; diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index ccec4df4f..380165a73 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -3,10 +3,24 @@ 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"; + 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,7 +28,7 @@ export function isModelAvailable( apiKeys: ApiKeyState, ): boolean { const provider = getModelProvider(modelId); - // Unknown/dynamic model IDs are only reachable through Concentrate. + // 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 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 4ae8eed5e..d97b98412 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -101,7 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { creditsResetDate: futureResetDate.toISOString(), creditsRemaining: 999999, // temporarily unlimited tier: "Free", - tabularModel: "gemini-2.0-flash", + tabularModel: "gemini-3-flash-preview", apiKeys: emptyApiKeys(), }); } finally { From 7cb8222ca404b97f65b3386b36adb74c08f01a70 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Tue, 2 Jun 2026 17:10:35 +0000 Subject: [PATCH 06/17] feat: lazy model catalog fetch + manual refresh in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model catalogs now fetch on picker open rather than component mount, so no background network calls happen until the user actually wants to pick a model. The 5-minute in-memory cache makes repeat opens instant; stale cache is served indefinitely on network failure so the picker never goes empty. Adds a "Refresh model list" button to Settings → Models that clears all provider caches so the next picker open pulls fresh catalogs. The button is only shown when at least one API key is configured. Co-Authored-By: Claude Opus 4.6 --- .../src/app/(pages)/account/models/page.tsx | 39 ++++++++++++++++++- .../app/components/assistant/ModelToggle.tsx | 34 +++++++--------- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 1091daa21..27533ffde 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 = [ { @@ -48,6 +50,23 @@ const API_KEY_FIELDS = [ 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 (
@@ -78,6 +97,24 @@ export default function ModelsAndApiKeysPage() { } />
+ {hasAnyKey && ( +
+ + + Model lists update automatically when you open the picker. + +
+ )}
diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index 987b466b1..ecabf6737 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -158,9 +158,12 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { const hasOpenAI = !!apiKeys?.openai?.configured; const hasConcentrateKey = !!apiKeys?.concentrate?.configured; - // Fetch each catalog the user has a key for. Independent of each other, - // each writes into its own slot of the catalogs state on resolve. + // 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, @@ -168,37 +171,26 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { slot: keyof ProviderCatalogs, ) => { if (!has) { - if (!cancelled) { - setDirect((prev) => ({ ...prev, [slot]: [] })); - } + if (!cancelled) setDirect((prev) => ({ ...prev, [slot]: [] })); return; } const models = await getProviderModels(provider); - if (!cancelled) { - setDirect((prev) => ({ ...prev, [slot]: models })); - } + if (!cancelled) setDirect((prev) => ({ ...prev, [slot]: models })); }; load("anthropic", hasClaude, "anthropic"); load("openai", hasOpenAI, "openai"); load("google", hasGemini, "google"); - return () => { - cancelled = true; - }; - }, [hasClaude, hasGemini, hasOpenAI]); - - useEffect(() => { if (!hasConcentrateKey) { - setConcentrate([]); - return; + if (!cancelled) setConcentrate([]); + } else { + getConcentrateModels().then((m) => { + if (!cancelled) setConcentrate(m); + }); } - let cancelled = false; - getConcentrateModels().then((m) => { - if (!cancelled) setConcentrate(m); - }); return () => { cancelled = true; }; - }, [hasConcentrateKey]); + }, [isOpen, hasClaude, hasGemini, hasOpenAI, hasConcentrateKey]); const merged = useMemo( () => mergeAll(direct, concentrate, hasConcentrateKey), From f9a8e22bb34e96d30443424a370a32b16d37dfbe Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Tue, 2 Jun 2026 23:22:38 +0000 Subject: [PATCH 07/17] fix: show correct model label before catalog loads - useSelectedModel: replace useState+useEffect pattern with lazy initializer so the stored model ID is read synchronously on first render, before the catalog fetch completes - ModelToggle: add labelFromId() fallback so stored IDs not in STATIC_FALLBACK (e.g. claude-opus-4-8 from a previous session) still display a readable label rather than "Model" --- .../src/app/components/assistant/ModelToggle.tsx | 14 +++++++++++++- frontend/src/app/hooks/useSelectedModel.ts | 13 +++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index ecabf6737..be34c4cf7 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -124,6 +124,18 @@ function mergeAll( 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[] = []; @@ -198,7 +210,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { ); const selected = merged.find((m) => m.id === value); - const selectedLabel = selected?.label ?? "Model"; + const selectedLabel = selected?.label ?? labelFromId(value); const selectedAvailable = apiKeys ? selected?.concentrateOnly ? hasConcentrateKey diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 12d7a05c6..20a654769 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; const STORAGE_KEY = "mike.selectedModel"; @@ -23,11 +23,12 @@ function readStored(): string { } 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 = looksLikeModelId(id) ? id : DEFAULT_MODEL_ID; From 309ddd3689c7d1e41604dbfb03664ff7edb15dea Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 01:25:03 +0000 Subject: [PATCH 08/17] fix: broaden OpenAI chat-capable model detection for gpt-5/o-series - Add date-stamped variants (gpt-5-2025-08-07, o3-2025-04-16, etc.) - Add pro variants (gpt-5-pro, o1-pro, o3-pro) - Add chat-latest aliases (chat-latest, gpt-5-chat-latest, etc.) - Block codex and deep-research IDs (404 on chat completions API) - Collapse o-series regex to single pattern covering o1-o9 with mini/preview/pro suffixes and date stamps --- backend/src/routes/providerModels.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/providerModels.ts b/backend/src/routes/providerModels.ts index 044d34014..18c49bc2b 100644 --- a/backend/src/routes/providerModels.ts +++ b/backend/src/routes/providerModels.ts @@ -117,15 +117,14 @@ const OPENAI_FAMILIES: Array<{ tools: boolean; streaming: boolean; }> = [ - // gpt-5* family — full support - { test: /^gpt-5(\.\d+)?(-(mini|nano|turbo))?(-\d+)?$/, chat: true, tools: true, streaming: true }, - // gpt-4o, gpt-4o-mini, gpt-4-turbo etc. + // 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 }, - // o3, o4-mini etc. — reasoning models, support tools+streaming on Responses API - { test: /^o[3-9](-(mini|preview))?$/, chat: true, tools: true, streaming: true }, - // o1, o1-mini, o1-preview — early reasoning; tools support was added later - // but is universal on current Responses API. Stream-via-Responses works. - { test: /^o1(-(mini|preview))?$/, 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"] { @@ -140,6 +139,8 @@ function openaiCapabilities(id: string): ProviderCatalogModel["capabilities"] { id.includes("moderation") || id.includes("realtime") || id.includes("search") || + id.includes("codex") || + id.includes("deep-research") || id.startsWith("ft:") ) { return { ...ZERO_CAPABILITIES }; From 7df7fbd08c24e91409f91d9c6160083c495ad2e5 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 01:39:37 +0000 Subject: [PATCH 09/17] fix: treat chat-latest aliases as OpenAI provider chat-latest and *-chat-latest IDs have no gpt-/o prefix so providerForModel() and inferProviderFromId() fell through to concentrate/null, showing the red unavailable badge in the picker even with an OpenAI key configured. --- backend/src/lib/llm/models.ts | 1 + frontend/src/app/lib/modelAvailability.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index d8333f389..7c1c8a381 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -50,6 +50,7 @@ export function providerForModel(model: string): Provider { if (model.startsWith("gemini")) return "gemini"; if (model.startsWith("gpt-")) return "openai"; if (/^o[1-9]/.test(model)) return "openai"; + if (model.endsWith("-chat-latest") || model === "chat-latest") return "openai"; return "concentrate"; } diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 380165a73..b1aa06ca0 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -11,6 +11,7 @@ function inferProviderFromId(modelId: string): ModelProvider | null { 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; } From f2b23e6d0f59f9e6a0e1b30f93d0e575696f5445 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 01:44:14 +0000 Subject: [PATCH 10/17] fix: label chat-latest aliases as GPT-latest in model picker --- backend/src/routes/providerModels.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/routes/providerModels.ts b/backend/src/routes/providerModels.ts index 18c49bc2b..b3805c513 100644 --- a/backend/src/routes/providerModels.ts +++ b/backend/src/routes/providerModels.ts @@ -97,8 +97,10 @@ type OpenAIModel = { }; 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"); From 312afe8e15fc89829b6bc9e4f64ba66a173ef978 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 02:57:09 +0000 Subject: [PATCH 11/17] fix: accept dynamic catalog model IDs in routing and UI fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveModel() gated on a static ALL_MODELS allowlist, causing any model from the live catalog (gpt-4.1, claude-opus-4-8, etc.) to silently downgrade to the default model on chat and to reject on tabularModel save with "Unsupported tabularModel". The validation belongs at the system boundary (looksLikeModelId on frontend, pick() key check in the router) — not in a dispatch helper that sees IDs after they've already been accepted by the picker. Also align all gemini-3-flash-preview fallback strings to gemini-2.5-flash (the real slug) so schema, tabular components, and UI error-fallbacks agree. gemini-3-flash-preview was a fabricated ID that never existed. --- backend/src/lib/llm/models.ts | 8 +++++++- frontend/src/app/(pages)/account/models/page.tsx | 2 +- frontend/src/contexts/UserProfileContext.tsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 7c1c8a381..ba97c0705 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -58,7 +58,13 @@ 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/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 27533ffde..dca82cdeb 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -89,7 +89,7 @@ export default function ModelsAndApiKeysPage() { diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index d97b98412..df4d197f4 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -101,7 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { creditsResetDate: futureResetDate.toISOString(), creditsRemaining: 999999, // temporarily unlimited tier: "Free", - tabularModel: "gemini-3-flash-preview", + tabularModel: "gemini-2.5-flash", apiKeys: emptyApiKeys(), }); } finally { From 618cee2a14e8b8aa9c31b7b525ad2ce396a31b62 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 03:14:02 +0000 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20simplify=20.env.example=20?= =?UTF-8?q?=E2=80=94=20just=20add=20CONCENTRATE=5FAPI=5FKEY=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index d6541f808..cd8a616e9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,9 +19,3 @@ 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 - -# Optional: override the Concentrate Responses API base URL. Useful when -# pointing at a staging tier or a self-hosted Concentrate instance. The -# /v1/models catalog endpoint is derived from the same base by replacing -# the trailing /responses with /models, so a single override switches both. -# CONCENTRATE_RESPONSES_URL=https://api.concentrate.ai/v1/responses From 3ba2bf33778e20df7817a42da0d16ee79c7f90ba Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 03:26:03 +0000 Subject: [PATCH 13/17] fix: restore gemini-3-flash-preview as default model to match upstream --- backend/schema.sql | 2 +- backend/src/lib/llm/models.ts | 14 +++++++------- frontend/src/app/(pages)/account/models/page.tsx | 2 +- .../src/app/components/assistant/ModelToggle.tsx | 4 ++-- .../src/app/components/tabular/TRChatPanel.tsx | 2 +- .../app/components/tabular/TabularReviewView.tsx | 2 +- frontend/src/contexts/UserProfileContext.tsx | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index adf8d6780..df5308302 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -17,7 +17,7 @@ create table if not exists public.user_profiles ( tier text not null default 'Free', message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), - tabular_model text not null default 'gemini-2.5-flash', + tabular_model text not null default 'gemini-3-flash-preview', created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index ba97c0705..ba93a421b 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -6,25 +6,25 @@ import type { Provider } from "./types"; // Main-chat tier (top-end) — user picks one of these per message. export const CLAUDE_MAIN_MODELS = ["claude-opus-4-7", "claude-sonnet-4-6"] as const; export const GEMINI_MAIN_MODELS = [ - "gemini-2.5-pro", - "gemini-2.5-flash", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", ] as const; export const OPENAI_MAIN_MODELS = ["gpt-4o", "gpt-4o-mini"] as const; // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; -export const GEMINI_MID_MODELS = ["gemini-2.5-flash"] as const; +export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; export const OPENAI_MID_MODELS = ["gpt-4o-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; -export const GEMINI_LOW_MODELS = ["gemini-2.5-flash-lite"] as const; +export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; export const OPENAI_LOW_MODELS = ["gpt-4o-mini"] as const; -export const DEFAULT_MAIN_MODEL = "gemini-2.5-flash"; -export const DEFAULT_TITLE_MODEL = "gemini-2.5-flash-lite"; -export const DEFAULT_TABULAR_MODEL = "gemini-2.5-flash"; +export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; +export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; +export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview"; const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index dca82cdeb..27533ffde 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -89,7 +89,7 @@ export default function ModelsAndApiKeysPage() { diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index be34c4cf7..132a81647 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -44,7 +44,7 @@ export interface ModelOption { */ const STATIC_FALLBACK: ModelOption[] = [ { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, - { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash", group: "Google" }, + { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, { id: "gpt-4o-mini", label: "GPT-4o Mini", group: "OpenAI" }, ]; @@ -54,7 +54,7 @@ const STATIC_FALLBACK: ModelOption[] = [ * picker UI itself does NOT use this — it fetches live catalogs. */ export const MODELS: ModelOption[] = STATIC_FALLBACK; -export const DEFAULT_MODEL_ID = "gemini-2.5-flash"; +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"]; diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 4ca68e2d3..e066486b9 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -644,7 +644,7 @@ export function TRChatPanel({ }: Props) { const { profile, updateModelPreference } = useUserProfile(); const apiKeys = profile?.apiKeys; - const currentModel = profile?.tabularModel ?? "gemini-2.5-flash"; + const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; const [apiKeyModalProvider, setApiKeyModalProvider] = useState(null); const [chats, setChats] = useState([]); diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 23d133f8c..cd59d1211 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -93,7 +93,7 @@ export function TRView({ reviewId, projectId }: Props) { const router = useRouter(); const { profile } = useUserProfile(); const apiKeys = profile?.apiKeys; - const tabularModel = profile?.tabularModel ?? "gemini-2.5-flash"; + const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview"; useEffect(() => { const params = new URLSearchParams(window.location.search); diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index df4d197f4..d97b98412 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -101,7 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { creditsResetDate: futureResetDate.toISOString(), creditsRemaining: 999999, // temporarily unlimited tier: "Free", - tabularModel: "gemini-2.5-flash", + tabularModel: "gemini-3-flash-preview", apiKeys: emptyApiKeys(), }); } finally { From eadcb8c1b5aba2604e9b48a528fb81dd3e9af006 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 03:34:22 +0000 Subject: [PATCH 14/17] fix: restore upstream OpenAI model constants (gpt-5.5, gpt-5.4-mini/nano) and full static fallback list --- backend/src/lib/llm/models.ts | 6 +++--- frontend/src/app/components/assistant/ModelToggle.tsx | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index ba93a421b..1e54dfed3 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -9,18 +9,18 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3.1-pro-preview", "gemini-3-flash-preview", ] as const; -export const OPENAI_MAIN_MODELS = ["gpt-4o", "gpt-4o-mini"] as const; +export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4-mini"] as const; // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; -export const OPENAI_MID_MODELS = ["gpt-4o-mini"] as const; +export const OPENAI_MID_MODELS = ["gpt-5.4-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; -export const OPENAI_LOW_MODELS = ["gpt-4o-mini"] as const; +export const OPENAI_LOW_MODELS = ["gpt-5.4-nano"] as const; export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index 132a81647..1b0b1ac19 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -43,9 +43,12 @@ export interface ModelOption { * 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" }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, - { id: "gpt-4o-mini", label: "GPT-4o Mini", group: "OpenAI" }, + { id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" }, + { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" }, ]; /** From 42ad044c6f099cce1f763d8dc5c73f20b9b2edb1 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 03:36:46 +0000 Subject: [PATCH 15/17] chore: add migration to extend user_api_keys provider constraint for concentrate --- backend/migrations/001_add_concentrate_provider.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 backend/migrations/001_add_concentrate_provider.sql 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')); From febb5f01e2ce77710ddf80a6b9f413cae77e20a7 Mon Sep 17 00:00:00 2001 From: Todd Lieberman Date: Wed, 3 Jun 2026 03:46:07 +0000 Subject: [PATCH 16/17] fix: restore Check icon for selected model in picker; restore label mb-2 spacing --- frontend/src/app/(pages)/account/models/page.tsx | 2 +- frontend/src/app/components/assistant/ModelToggle.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 27533ffde..dee9ffc97 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -309,7 +309,7 @@ function ApiKeyField({ return (
- + {description && (

{description}

)} diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index 1b0b1ac19..e8d0603f7 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { ChevronDown, AlertCircle, Shield } from "lucide-react"; +import { ChevronDown, AlertCircle, Check, Shield } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -284,6 +284,9 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { aria-label="API key missing" /> )} + {isSelected && available && ( + + )} {m.zdr && ( Date: Wed, 3 Jun 2026 05:08:21 +0000 Subject: [PATCH 17/17] fix: clear provider model cache when key state changes to avoid stale empty catalog --- frontend/src/app/components/assistant/ModelToggle.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index e8d0603f7..3995707c8 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -18,6 +18,7 @@ import { } from "@/app/lib/concentrateModels"; import { getProviderModels, + clearProviderModelsCache, type ProviderId, type ProviderModel, } from "@/app/lib/providerModels"; @@ -173,6 +174,13 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) { 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