diff --git a/backend/src/core/apiKeyProviders.ts b/backend/src/core/apiKeyProviders.ts new file mode 100644 index 000000000..939be6aed --- /dev/null +++ b/backend/src/core/apiKeyProviders.ts @@ -0,0 +1,66 @@ +export type ApiKeyProvider = string; +export type ApiKeySource = "user" | "env" | null; + +type ProviderRecord = { + readonly envVars: readonly string[]; +}; + +// Table-driven: env-var names live here, not in a switch statement. +// Adding a new provider is one registerApiKeyProvider() call — no edits here. +const _providerRegistry = new Map([ + ["claude", { envVars: ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] }], + ["gemini", { envVars: ["GEMINI_API_KEY"] }], + ["openai", { envVars: ["OPENAI_API_KEY"] }], + ["openrouter", { envVars: ["OPENROUTER_API_KEY"] }], + ["courtlistener", { envVars: ["COURTLISTENER_API_TOKEN"] }], +]); + +/** + * Register a new API-key provider so that getUserApiKeyStatus() and + * getUserApiKeys() include it automatically. + * + * Call once from your provider setup file alongside registerProvider(): + * + * registerApiKeyProvider("bedrock", ["AWS_ACCESS_KEY_ID"]); + * registerApiKeyProvider("ollama", []); // no key required + */ +export function registerApiKeyProvider( + provider: string, + envVars: readonly string[], +): void { + _providerRegistry.set(provider, { envVars }); +} + +/** Returns provider IDs in registration order. */ +export function getRegisteredProviders(): readonly string[] { + return [..._providerRegistry.keys()]; +} + +export function isApiKeyProvider(value: string): boolean { + return _providerRegistry.has(value); +} + +export function normalizeApiKeyProvider(value: string): string | null { + return _providerRegistry.has(value) ? value : null; +} + +/** + * Returns the platform API key for provider from environment variables, + * or null when none of the provider's env vars are set. + * + * Table-driven: the env var names are declared in the provider registry above, + * not hard-coded per-provider in this function body. + */ +export function envApiKey(provider: string): string | null { + const record = _providerRegistry.get(provider); + if (!record) return null; + for (const varName of record.envVars) { + const val = process.env[varName]?.trim(); + if (val) return val; + } + return null; +} + +export function hasEnvApiKey(provider: string): boolean { + return !!envApiKey(provider); +} diff --git a/backend/src/lib/llm/__tests__/registry.test.ts b/backend/src/lib/llm/__tests__/registry.test.ts new file mode 100644 index 000000000..338063d28 --- /dev/null +++ b/backend/src/lib/llm/__tests__/registry.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + registerProvider, + getRegisteredProvider, + findProviderForModel, + registeredProviderIds, + allRegisteredModels, + _resetRegistryForTesting, + type LLMProviderAdapter, +} from "../registry"; + +function makeAdapter(id: string, prefixes: string[], models: string[] = []): LLMProviderAdapter { + return { + id, + matchesModel: (m) => prefixes.some((p) => m.startsWith(p)), + stream: async () => ({ fullText: "" }), + complete: async () => "", + models: { main: models, mid: [], low: [] }, + }; +} + +beforeEach(() => { + _resetRegistryForTesting(); +}); + +describe("registerProvider / getRegisteredProvider", () => { + it("stores and retrieves an adapter by id", () => { + const adapter = makeAdapter("test", ["test-"]); + registerProvider(adapter); + expect(getRegisteredProvider("test")).toBe(adapter); + }); + + it("returns undefined for an unknown id", () => { + expect(getRegisteredProvider("unknown")).toBeUndefined(); + }); + + it("re-registration replaces the previous adapter", () => { + const first = makeAdapter("p", ["p-"]); + const second = makeAdapter("p", ["p-"]); + registerProvider(first); + registerProvider(second); + expect(getRegisteredProvider("p")).toBe(second); + }); +}); + +describe("findProviderForModel", () => { + it("returns the first provider whose matchesModel is true", () => { + const a = makeAdapter("alpha", ["alpha-"]); + const b = makeAdapter("beta", ["beta-"]); + registerProvider(a); + registerProvider(b); + expect(findProviderForModel("alpha-turbo")).toBe(a); + expect(findProviderForModel("beta-fast")).toBe(b); + }); + + it("returns undefined when no provider matches", () => { + registerProvider(makeAdapter("x", ["x-"])); + expect(findProviderForModel("unknown-model")).toBeUndefined(); + }); + + it("the first registered provider wins on overlap", () => { + const first = makeAdapter("first", ["shared-"]); + const second = makeAdapter("second", ["shared-"]); + registerProvider(first); + registerProvider(second); + expect(findProviderForModel("shared-model")).toBe(first); + }); +}); + +describe("registeredProviderIds", () => { + it("returns ids in insertion order", () => { + registerProvider(makeAdapter("c", ["c-"])); + registerProvider(makeAdapter("a", ["a-"])); + registerProvider(makeAdapter("b", ["b-"])); + expect(registeredProviderIds()).toEqual(["c", "a", "b"]); + }); + + it("returns an empty array when no providers are registered", () => { + expect(registeredProviderIds()).toEqual([]); + }); +}); + +describe("allRegisteredModels", () => { + it("returns the union of all provider model lists", () => { + registerProvider(makeAdapter("p1", ["m-"], ["m1", "m2"])); + registerProvider(makeAdapter("p2", ["n-"], ["m2", "n1"])); + const set = allRegisteredModels(); + expect(set.has("m1")).toBe(true); + expect(set.has("m2")).toBe(true); + expect(set.has("n1")).toBe(true); + expect(set.size).toBe(3); + }); + + it("returns an empty set when no providers are registered", () => { + expect(allRegisteredModels().size).toBe(0); + }); +}); diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 4b5e97936..8bb07c36d 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,30 +1,99 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; import { streamOpenAI, completeOpenAIText } from "./openai"; -import { providerForModel } from "./models"; -import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; +import { registerProvider, getRegisteredProvider } from "./registry"; +import { + providerForModel, + CLAUDE_MAIN_MODELS, + CLAUDE_MID_MODELS, + CLAUDE_LOW_MODELS, + GEMINI_MAIN_MODELS, + GEMINI_MID_MODELS, + GEMINI_LOW_MODELS, + OPENAI_MAIN_MODELS, + OPENAI_MID_MODELS, + OPENAI_LOW_MODELS, +} from "./models"; +import type { StreamChatParams, StreamChatResult, CompleteTextParams } from "./types"; export * from "./types"; export * from "./models"; +/** + * Register a third-party LLM provider so it is available via + * streamChatWithTools() and completeText(). + * + * OpenAI-compatible providers can be added the same way — call + * registerProvider()/registerApiKeyProvider(), no core edits. + */ +export { registerProvider } from "./registry"; +import { setupDemo } from "./providers/demo"; + +// --------------------------------------------------------------------------- +// Register built-in providers +// --------------------------------------------------------------------------- +// Providers are imported above so that Vitest's vi.mock() hoisting works: +// test files mock e.g. "../claude" before this module loads, so the mocked +// function is captured here and ends up in the registry. + +/** Register the built-in LLM providers (claude/gemini/openai). */ +export function registerBuiltinProviders(): void { + // Keyless demo model — always available. Lets a brand-new user get a + // response before any API key is configured, and backs the auto-fallback + // in routes/chat.ts. + setupDemo(); + + registerProvider({ + id: "claude", + matchesModel: (m) => m.startsWith("claude"), + stream: streamClaude, + complete: completeClaudeText, + models: { main: CLAUDE_MAIN_MODELS, mid: CLAUDE_MID_MODELS, low: CLAUDE_LOW_MODELS }, + }); + registerProvider({ + id: "gemini", + matchesModel: (m) => m.startsWith("gemini"), + stream: streamGemini, + complete: completeGeminiText, + models: { main: GEMINI_MAIN_MODELS, mid: GEMINI_MID_MODELS, low: GEMINI_LOW_MODELS }, + }); + registerProvider({ + id: "openai", + matchesModel: (m) => m.startsWith("gpt-"), + stream: streamOpenAI, + complete: completeOpenAIText, + models: { main: OPENAI_MAIN_MODELS, mid: OPENAI_MID_MODELS, low: OPENAI_LOW_MODELS }, + }); +} + +registerBuiltinProviders(); + +// --------------------------------------------------------------------------- +// Public dispatch +// --------------------------------------------------------------------------- + +function requireAdapter(providerId: string, model: string) { + const adapter = getRegisteredProvider(providerId); + if (!adapter) { + throw new Error( + `LLM provider "${providerId}" matched model "${model}" but is not registered. ` + + `Import "lib/llm" to initialize built-in providers, ` + + `or call registerProvider() for third-party providers.`, + ); + } + return adapter; +} + 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 providerId = providerForModel(params.model); + const adapter = requireAdapter(providerId, params.model); + return adapter.stream(params); } -export async function completeText(params: { - model: string; - systemPrompt?: string; - user: string; - 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); +export async function completeText(params: CompleteTextParams): Promise { + const providerId = providerForModel(params.model); + const adapter = requireAdapter(providerId, params.model); + return adapter.complete(params); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 16b7bb3db..29f592bc4 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -1,7 +1,7 @@ -import type { Provider } from "./types"; +import { findProviderForModel, allRegisteredModels } from "./registry"; // --------------------------------------------------------------------------- -// Canonical model IDs +// Canonical model IDs (built-in providers) // --------------------------------------------------------------------------- // Main-chat tier (top-end) — user picks one of these per message. export const CLAUDE_MAIN_MODELS = [ @@ -32,6 +32,28 @@ 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"; +/** + * Built-in keyless "demo" model. Requires no API key and returns a canned, + * context-aware placeholder answer. Used as the automatic fallback when a + * request's chosen provider has no configured key, so a brand-new user still + * gets a response (and a nudge to add a real key) instead of a hard error. + * Also selectable directly in the model picker. Registered by + * providers/demo.ts. + */ +export const DEMO_MODEL = "mike-demo"; + +// Derived (not hand-maintained) fallback set for resolveModel(). +// Built by spreading the *_MODELS arrays above, so adding a model to any +// of those arrays automatically includes it here — no second edit site. +// +// Why keep this alongside allRegisteredModels()? Two reasons: +// 1. Test isolation: models.test.ts imports models.ts directly without +// importing index.ts, so no providers are registered and the registry +// is empty. ALL_MODELS provides the fallback in that case. +// 2. External providers registered via registerProvider() appear in +// allRegisteredModels() but NOT here — that's intentional. +// resolveModel() checks both, so external models are always accepted +// once their provider is registered. const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, ...GEMINI_MAIN_MODELS, @@ -48,14 +70,33 @@ const ALL_MODELS = new Set([ // Provider inference // --------------------------------------------------------------------------- -export function providerForModel(model: string): Provider { +/** + * Maps a model ID to its provider string. + * + * Registered providers are checked first so that externally registered + * adapters (Ollama, Bedrock, Azure) override the built-in prefix matching + * below — no edits to this file required to support a new provider. + * + * The prefix fallback keeps this function usable in test contexts that don't + * import index.ts and therefore don't trigger provider registration. + */ +export function providerForModel(model: string): string { + const registered = findProviderForModel(model); + if (registered) return registered.id; 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}`); } +/** + * Returns id if it is a recognised model, otherwise returns fallback. + * + * Checks the live registry first (includes externally registered models) then + * falls back to the static ALL_MODELS set so the function works in test + * contexts where no providers have been registered. + */ export function resolveModel(id: string | null | undefined, fallback: string): string { - if (id && ALL_MODELS.has(id)) return id; + if (id && (allRegisteredModels().has(id) || ALL_MODELS.has(id))) return id; return fallback; } diff --git a/backend/src/lib/llm/providers/demo.ts b/backend/src/lib/llm/providers/demo.ts new file mode 100644 index 000000000..b52197a42 --- /dev/null +++ b/backend/src/lib/llm/providers/demo.ts @@ -0,0 +1,137 @@ +/** + * Demo provider — a built-in, keyless model that returns a canned but + * context-aware placeholder answer. + * + * Why this exists: a brand-new instance (or a self-hoster who hasn't added a + * key yet) would otherwise hit a raw provider auth error on their very first + * question. There is no reliable free hosted LLM we can call without a key, so + * instead of failing we answer locally in "demo mode": we acknowledge the + * question and any shared documents, describe what a real model would do, and + * point the user at Settings → API Keys. No network call, no key required, so + * it works offline and in air-gapped mode too. + * + * Registered unconditionally by registerBuiltinProviders(). The chat route also + * routes to DEMO_MODEL automatically when the chosen provider has no configured + * key (see routes/chat.ts). + */ + +import { registerProvider } from "../registry"; +import { registerApiKeyProvider } from "../../../core/apiKeyProviders"; +import { DEMO_MODEL } from "../models"; +import type { + StreamChatParams, + StreamChatResult, + CompleteTextParams, +} from "../types"; + +// Match filename-looking tokens (no spaces, so we don't swallow surrounding +// prose like "…terms in nda.pdf"). +const FILENAME_RE = /\b[\w()\-]+\.(?:pdf|docx?|txt|md|csv)\b/gi; + +/** Pull any document filenames mentioned in the prompt so the demo reply can + * name what the user shared. Best-effort — returns [] when nothing matches. */ +function extractSharedDocuments(params: StreamChatParams): string[] { + const haystack = [ + params.systemPrompt ?? "", + ...params.messages.map((m) => m.content ?? ""), + ].join("\n"); + const found = new Set(); + for (const match of haystack.matchAll(FILENAME_RE)) { + const name = match[0].trim(); + // Skip absurdly long "filenames" that are really prose containing a dot. + if (name.length <= 80) found.add(name); + } + return [...found]; +} + +function lastUserQuestion(params: StreamChatParams): string { + for (let i = params.messages.length - 1; i >= 0; i--) { + if (params.messages[i].role === "user") { + return (params.messages[i].content ?? "").trim(); + } + } + return ""; +} + +/** Build the demo answer. Kept deterministic and clearly labelled so no one + * mistakes it for real legal analysis. */ +export function buildDemoAnswer(params: StreamChatParams): string { + const question = lastUserQuestion(params); + const docs = extractSharedDocuments(params); + + const lines: string[] = []; + lines.push( + "**Demo mode** — no AI provider key is configured, so Mike is replying with a placeholder instead of real analysis.", + ); + lines.push(""); + if (question) { + const trimmed = + question.length > 300 ? `${question.slice(0, 300)}…` : question; + lines.push(`You asked: *"${trimmed}"*`); + lines.push(""); + } + if (docs.length > 0) { + const list = docs.slice(0, 8).join(", "); + lines.push( + `You've shared **${docs.length} document${docs.length === 1 ? "" : "s"}** (${list}). ` + + "With a configured model, Mike would read them in full and extract the parties, governing law, " + + "key dates, obligations, payment and liability terms, and flag risks — each answer cited back to the " + + "exact source text.", + ); + } else { + lines.push( + "With a configured model, Mike answers questions about your uploaded documents — extracting parties, " + + "governing law, key dates, obligations and risks, with every answer cited back to the source text.", + ); + } + lines.push(""); + lines.push("**To get real answers:**"); + lines.push("1. Open **Settings → API Keys**"); + lines.push( + "2. Add an Anthropic, Google, or OpenAI key — or point Mike at a local model", + ); + lines.push("3. Re-send your question"); + lines.push(""); + lines.push( + "_Your documents stay in your workspace — nothing is sent to an AI provider until you add a key._", + ); + return lines.join("\n"); +} + +/** Emit `text` through onContentDelta in small chunks so the UI renders it as a + * normal streamed answer. Honours the abort signal. */ +async function streamDemoText( + text: string, + params: StreamChatParams, +): Promise { + const signal = params.abortSignal; + const onDelta = params.callbacks?.onContentDelta; + if (!onDelta) return { fullText: text }; + + // Chunk on word boundaries; keep the whitespace attached to each token. + const tokens = text.match(/\S+\s*/g) ?? [text]; + for (const token of tokens) { + if (signal?.aborted) break; + onDelta(token); + } + return { fullText: text }; +} + +export function setupDemo(): void { + // No credentials required. Registering with an empty env-var list keeps the + // provider out of "server key configured" accounting without needing a key. + registerApiKeyProvider("demo", []); + + registerProvider({ + id: "demo", + matchesModel: (m) => m === DEMO_MODEL, + stream: (params: StreamChatParams) => + streamDemoText(buildDemoAnswer(params), params), + complete: async (params: CompleteTextParams) => { + // Used for lightweight jobs (e.g. title generation). Return a short, + // safe string rather than a paragraph. + return params.user.trim().slice(0, 60) || "Demo chat"; + }, + models: { main: [DEMO_MODEL], mid: [], low: [] }, + }); +} diff --git a/backend/src/lib/llm/registry.ts b/backend/src/lib/llm/registry.ts new file mode 100644 index 000000000..76dfa1e0c --- /dev/null +++ b/backend/src/lib/llm/registry.ts @@ -0,0 +1,92 @@ +import type { StreamChatParams, StreamChatResult, CompleteTextParams } from "./types"; + +/** + * Contract every LLM provider adapter must satisfy. + * + * Built-in providers (Claude, Gemini, OpenAI) are registered in index.ts on + * module load. Third-party providers (Ollama, Bedrock, Azure, Mistral) call + * registerProvider() from their own setup file before the first LLM call. + * + * Adding a new provider is a single-file operation — no edits to index.ts, + * models.ts, or userApiKeys.ts are required. + */ +export interface LLMProviderAdapter { + /** Stable identifier, e.g. "claude", "gemini", "openai", "ollama". */ + readonly id: string; + /** + * Return true if this provider handles the given model string. + * Checked in registration order; the first match wins. + */ + matchesModel(model: string): boolean; + /** Streaming chat with optional tool-call loop. */ + stream(params: StreamChatParams): Promise; + /** Single-shot non-streaming text completion. */ + complete(params: CompleteTextParams): Promise; + /** + * Model IDs grouped by usage tier. + * Drives the global valid-model set so resolveModel() recognises + * externally registered models without hard-coding them in models.ts. + */ + readonly models: { + readonly main: readonly string[]; + readonly mid: readonly string[]; + readonly low: readonly string[]; + }; +} + +const _registry = new Map(); + +/** + * Register an LLM provider adapter. + * + * Call once per provider, typically at application startup or when the + * provider's setup module is first imported. Re-registering an id replaces + * the previous entry. + */ +export function registerProvider(adapter: LLMProviderAdapter): void { + _registry.set(adapter.id, adapter); +} + +/** Returns the adapter registered under id, or undefined if none. */ +export function getRegisteredProvider(id: string): LLMProviderAdapter | undefined { + return _registry.get(id); +} + +/** + * Returns the first registered provider whose matchesModel() returns true, + * or undefined when none match. + * + * models.ts calls this before falling back to built-in prefix heuristics, + * so externally registered providers can override routing for any model ID. + */ +export function findProviderForModel(model: string): LLMProviderAdapter | undefined { + for (const p of _registry.values()) { + if (p.matchesModel(model)) return p; + } + return undefined; +} + +/** IDs of all currently registered providers in insertion order. */ +export function registeredProviderIds(): string[] { + return [..._registry.keys()]; +} + +/** + * Union of every model ID declared across all registered providers. + * resolveModel() in models.ts calls this so that externally added models + * are validated without requiring changes to the static ALL_MODELS set. + */ +export function allRegisteredModels(): Set { + const set = new Set(); + for (const p of _registry.values()) { + for (const m of [...p.models.main, ...p.models.mid, ...p.models.low]) { + set.add(m); + } + } + return set; +} + +/** Exposed for test isolation only — do not call in production code. */ +export function _resetRegistryForTesting(): void { + _registry.clear(); +} diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 6a9f18acf..e47838d1c 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -2,7 +2,8 @@ // Callers always speak OpenAI-style tools + { role, content } messages; each // provider translates internally. -export type Provider = "claude" | "gemini" | "openai"; +/** Provider identifier string — extensible, not a closed union. */ +export type Provider = string; export type OpenAIToolSchema = { type: "function"; @@ -36,12 +37,31 @@ export type StreamCallbacks = { onToolCallStart?: (call: NormalizedToolCall) => void; }; +/** + * Per-request API keys keyed by provider id. + * + * The three named optional properties exist solely for IDE autocomplete on + * the built-in providers — they are NOT a closed list. The index signature + * makes this map open: third-party providers (e.g. "ollama", "bedrock") carry + * their credentials here without any changes to this file. Callers access + * keys via apiKeys[providerId], not via named property access. + */ export type UserApiKeys = { claude?: string | null; gemini?: string | null; openai?: string | null; openrouter?: string | null; courtlistener?: string | null; + [provider: string]: string | null | undefined; +}; + +/** Parameters for the single-shot non-streaming completeText() call. */ +export type CompleteTextParams = { + model: string; + systemPrompt?: string; + user: string; + maxTokens?: number; + apiKeys?: UserApiKeys; }; export type StreamChatParams = { diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts index 27f617c98..f78fc9b18 100644 --- a/backend/src/lib/userApiKeys.ts +++ b/backend/src/lib/userApiKeys.ts @@ -1,17 +1,24 @@ import crypto from "crypto"; import { createServerSupabase } from "./supabase"; import type { UserApiKeys } from "./llm"; +import { + envApiKey, + hasEnvApiKey, + normalizeApiKeyProvider, + getRegisteredProviders, + type ApiKeyProvider, + type ApiKeySource, +} from "../core/apiKeyProviders"; type Db = ReturnType; -export type ApiKeyProvider = - | "claude" - | "gemini" - | "openai" - | "openrouter" - | "courtlistener"; -export type ApiKeySource = "user" | "env" | null; -export type ApiKeyStatus = Record & { - sources: Record; + +/** + * Status record keyed by provider id. + * Derived dynamically from the registered provider list so new providers + * added via registerApiKeyProvider() appear here automatically. + */ +export type ApiKeyStatus = Record & { + sources: Record; }; type EncryptedKeyRow = { @@ -21,38 +28,7 @@ type EncryptedKeyRow = { auth_tag: string; }; -const PROVIDERS: ApiKeyProvider[] = [ - "claude", - "gemini", - "openai", - "openrouter", - "courtlistener", -]; - -function envApiKey(provider: ApiKeyProvider): string | null { - switch (provider) { - case "claude": - return ( - process.env.ANTHROPIC_API_KEY?.trim() || - process.env.CLAUDE_API_KEY?.trim() || - null - ); - case "gemini": - return process.env.GEMINI_API_KEY?.trim() || null; - case "openai": - return process.env.OPENAI_API_KEY?.trim() || null; - case "openrouter": - return process.env.OPENROUTER_API_KEY?.trim() || null; - case "courtlistener": - return process.env.COURTLISTENER_API_TOKEN?.trim() || null; - default: - return null; - } -} - -export function hasEnvApiKey(provider: ApiKeyProvider): boolean { - return !!envApiKey(provider); -} +export { hasEnvApiKey, normalizeApiKeyProvider }; function encryptionKey(): Buffer { const secret = process.env.USER_API_KEYS_ENCRYPTION_SECRET; @@ -98,34 +74,21 @@ function decrypt(row: EncryptedKeyRow): string | null { } } -function isProvider(value: string): value is ApiKeyProvider { - return (PROVIDERS as string[]).includes(value); -} - -export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null { - return isProvider(value) ? value : null; -} - export async function getUserApiKeyStatus( userId: string, db: Db = createServerSupabase(), ): Promise { - const status: ApiKeyStatus = { - claude: false, - gemini: false, - openai: false, - openrouter: false, - courtlistener: false, - sources: { - claude: null, - gemini: null, - openai: null, - openrouter: null, - courtlistener: null, - }, - }; + // Build status object dynamically from the registered provider list so new + // providers appear here without any manual addition to this function. + const providers = getRegisteredProviders(); + const status: ApiKeyStatus = {} as ApiKeyStatus; + status.sources = {} as Record; + for (const provider of providers) { + status[provider] = false; + status.sources[provider] = null; + } - for (const provider of PROVIDERS) { + for (const provider of providers) { if (hasEnvApiKey(provider)) { status[provider] = true; status.sources[provider] = "env"; @@ -153,13 +116,11 @@ export async function getUserApiKeys( userId: string, db: Db = createServerSupabase(), ): Promise { - const apiKeys: UserApiKeys = { - claude: envApiKey("claude"), - gemini: envApiKey("gemini"), - openai: envApiKey("openai"), - openrouter: envApiKey("openrouter"), - courtlistener: envApiKey("courtlistener"), - }; + // Seed from env vars for all registered providers. + const apiKeys: UserApiKeys = {}; + for (const provider of getRegisteredProviders()) { + apiKeys[provider] = envApiKey(provider); + } const { data, error } = await db .from("user_api_keys") diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2bb3dfda6..87e2fb1b4 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -17,7 +17,13 @@ import { parseAskInputsResponsePayload, type ChatMessage, } from "../lib/chat"; -import { completeText } from "../lib/llm"; +import { + completeText, + DEFAULT_MAIN_MODEL, + DEMO_MODEL, + providerForModel, + resolveModel, +} from "../lib/llm"; import { getUserModelSettings, } from "../lib/userSettings"; @@ -26,6 +32,29 @@ import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; export const chatRouter = Router(); +/** + * Pick the model to actually run. If the requested model's provider has no + * usable key (env or per-user), fall back to the keyless demo model so the user + * gets a helpful placeholder instead of a raw provider auth error. An explicit + * demo request, or a provider with a key present, is returned unchanged. + * Exported for unit testing. + */ +export function resolveDemoFallback( + requestedModel: string | null | undefined, + apiKeys: Record, +): string { + const model = resolveModel(requestedModel, DEFAULT_MAIN_MODEL); + if (model === DEMO_MODEL) return model; + let provider: string; + try { + provider = providerForModel(model); + } catch { + return model; // unknown model — let the normal path surface the error + } + if (provider === "demo") return model; + return apiKeys[provider]?.trim() ? model : DEMO_MODEL; +} + type Db = ReturnType; const isDev = process.env.NODE_ENV !== "production"; const devLog = (...args: Parameters) => { @@ -395,8 +424,12 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { userId, db, ); + // Titles go through the same keyless-demo fallback as the streaming + // route — without it a keyless demo chat 500s here the moment the + // first reply finishes. + const effectiveTitleModel = resolveDemoFallback(title_model, api_keys); const titleText = await completeText({ - model: title_model, + model: effectiveTitleModel, user: `Generate a concise title (3–6 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`, maxTokens: 64, apiKeys: api_keys, @@ -557,6 +590,12 @@ chatRouter.post("/", requireAuth, async (req, res) => { const workflowStore = await buildWorkflowStore(userId, userEmail, db); + // Keyless-demo fallback: if the chosen model's provider has no configured + // key, answer in demo mode instead of failing with a raw provider auth + // error. An explicitly selected demo model is left as-is. This is what makes + // a brand-new instance usable before any key is set up. + const effectiveModel = resolveDemoFallback(model, apiKeys); + devLog("[chat/stream] starting LLM stream", { apiMessageCount: apiMessages.length, docCount: Object.keys(docIndex).length, @@ -588,7 +627,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { write, workflowStore, includeResearchTools: legalResearchUs, - model, + model: effectiveModel, apiKeys, signal: streamAbort.signal, projectId: resolvedProjectId, diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 56ea6efb5..28007e49f 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -22,6 +22,7 @@ import { getUserModelSettings, } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { resolveDemoFallback } from "./chat"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: @@ -203,7 +204,10 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { extraTools: PROJECT_EXTRA_TOOLS, workflowStore, includeResearchTools: legalResearchUs, - model, + // Same keyless-demo fallback as the main chat route: a keyless + // request carrying a real model id answers in demo mode instead + // of surfacing a raw provider auth error. + model: resolveDemoFallback(model, apiKeys), apiKeys, signal: streamAbort.signal, projectId, diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index ffd5066d8..4f08e9c30 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -9,6 +9,7 @@ import { SidebarContext } from "@/app/contexts/SidebarContext"; import { PageChromeContext } from "@/app/contexts/PageChromeContext"; import { AppSidebar } from "@/app/components/shared/AppSidebar"; import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader"; +import { ApiKeyBanner } from "@/app/components/shared/ApiKeyBanner"; export default function MikeLayout({ children, @@ -100,6 +101,7 @@ export default function MikeLayout({ }} >
+
(function ChatInput( const handleSubmit = () => { const query = value.trim(); if (!query || isLoading) return; + // Zero keys configured: fall back to the keyless demo model so the + // user still gets an answer (the demo reply and the global banner + // both nudge them to add a real key). But if the user HAS a key and + // merely picked a model from a provider they haven't configured, keep + // the explanatory popup — silently answering in demo mode would be + // wrong and confusing for them. + let effectiveModel = model; if (apiKeys && !isModelAvailable(model, apiKeys)) { - setApiKeyModalProvider(getModelProvider(model)); - return; + if (anyModelKeyConfigured(apiKeys)) { + setApiKeyModalProvider(getModelProvider(model)); + return; + } + effectiveModel = DEMO_MODEL_ID; } setValue(""); if (textareaRef.current) { @@ -275,7 +286,7 @@ export const ChatInput = forwardRef(function ChatInput( content: query, files: files.length > 0 ? files : undefined, workflow: wf ?? undefined, - model, + model: effectiveModel, }); }; diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index abd20b9b5..5d2771569 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -18,9 +18,13 @@ import type { ApiKeyState } from "@/app/lib/mikeApi"; export interface ModelOption { id: string; label: string; - group: "Anthropic" | "Google" | "OpenAI"; + group: "Anthropic" | "Google" | "OpenAI" | "Demo"; } +/** Keyless built-in model — always available, returns a placeholder answer. + * Mirrors DEMO_MODEL in backend/src/lib/llm/models.ts. */ +export const DEMO_MODEL_ID = "mike-demo"; + export const MODELS: ModelOption[] = [ { id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" }, { id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" }, @@ -31,6 +35,7 @@ export const MODELS: ModelOption[] = [ { 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", label: "GPT-5.4", group: "OpenAI" }, + { id: DEMO_MODEL_ID, label: "Demo (no key needed)", group: "Demo" }, ]; export const SETTINGS_MODELS: ModelOption[] = [ @@ -48,7 +53,7 @@ export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"]; +const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI", "Demo"]; const itemClassName = "rounded-xl px-2.5 py-1.5 text-gray-700 focus:bg-app-surface-hover focus:text-gray-900 data-[highlighted]:bg-app-surface-hover data-[highlighted]:text-gray-900"; diff --git a/frontend/src/app/components/shared/ApiKeyBanner.tsx b/frontend/src/app/components/shared/ApiKeyBanner.tsx new file mode 100644 index 000000000..e454846de --- /dev/null +++ b/frontend/src/app/components/shared/ApiKeyBanner.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { KeyRound, X } from "lucide-react"; +import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { anyModelKeyConfigured } from "@/app/lib/modelAvailability"; + +const DISMISS_KEY = "apiKeyBannerDismissed"; + +/** + * Global banner shown on every authenticated page when no AI provider key is + * configured. Dismissible for the current tab session (sessionStorage) so it + * returns on the next visit until a key is added. Hidden on the account/setup + * pages where it would be redundant. + */ +export function ApiKeyBanner() { + const { profile } = useUserProfile(); + const pathname = usePathname(); + const [dismissed, setDismissed] = useState(() => { + if (typeof window === "undefined") return true; + return sessionStorage.getItem(DISMISS_KEY) === "true"; + }); + + // Wait for the profile before deciding, to avoid a flash on load. + if (!profile) return null; + if (dismissed) return null; + if (pathname?.startsWith("/account")) return null; + + if (anyModelKeyConfigured(profile.apiKeys)) return null; + + const handleDismiss = () => { + sessionStorage.setItem(DISMISS_KEY, "true"); + setDismissed(true); + }; + + return ( +
+ +

+ No AI provider key is set up.{" "} + + Mike is answering in demo mode — add a key to get real + document analysis. + +

+ + Set up API keys + + +
+ ); +} diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index fb0f09abf..d474dcd90 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,11 +1,15 @@ -import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle"; +import { + SETTINGS_MODELS, + DEMO_MODEL_ID, + type ModelOption, +} from "../components/assistant/ModelToggle"; import type { ApiKeyState } from "@/app/lib/mikeApi"; export type ModelProvider = "claude" | "gemini" | "openai"; export function getModelProvider(modelId: string): ModelProvider | null { const model = SETTINGS_MODELS.find((m) => m.id === modelId); - if (!model) return null; + if (!model || model.group === "Demo") return null; return modelGroupToProvider(model.group); } @@ -13,6 +17,9 @@ export function isModelAvailable( modelId: string, apiKeys: ApiKeyState, ): boolean { + // The demo model is keyless — always available so a user with no keys can + // still send a message. + if (modelId === DEMO_MODEL_ID) return true; const provider = getModelProvider(modelId); if (!provider) return false; return isProviderAvailable(provider, apiKeys); @@ -25,6 +32,13 @@ export function isProviderAvailable( return !!apiKeys[provider]?.configured; } +const MODEL_PROVIDERS: readonly ModelProvider[] = ["claude", "gemini", "openai"]; + +/** True when at least one chat-model provider has a key configured. */ +export function anyModelKeyConfigured(apiKeys: ApiKeyState): boolean { + return MODEL_PROVIDERS.some((p) => isProviderAvailable(p, apiKeys)); +} + export function providerLabel(provider: ModelProvider): string { if (provider === "claude") return "Anthropic (Claude)"; if (provider === "openai") return "OpenAI";