diff --git a/.env.example b/.env.example index 1f43fff..be699b8 100644 --- a/.env.example +++ b/.env.example @@ -35,10 +35,21 @@ POSTGRES_DB=mydb_dev # is handed this URL by compose; it is named here only so host-side tools (pg_dump, psql) can # reach it deliberately. POSTGRES_PROD_DB=mydb +# API KEYS ONLY. Which provider and model Cameron uses is chosen in the app (Settings) GOOGLE_API_KEY=your_google_api_key_here OPENAI_API_KEY=your_openai_api_key_here ANTHROPIC_API_KEY=your_anthropic_api_key_here +# The "OpenAI Compatible" provider (Ollama, vLLM, LM Studio, Groq, OpenRouter, DeepSeek). Its base +# URL is entered in Settings; only the key lives here, and local endpoints usually need none. +# Left unset a dummy is sent — never your OPENAI_API_KEY, which the SDK would otherwise pick up +# and send to that URL. +# OPENAI_COMPATIBLE_API_KEY= +# +# Set true if the endpoint rejects standard JSON Schema keywords in tool definitions +# (an opaque 400 on the first tool call). Strips them the way the Google provider does. +# OPENAI_COMPATIBLE_SANITIZE_SCHEMA=false + # S3-Compatible Object Storage Configuration # Development: MinIO (local Docker) # Production: AWS S3, Cloudflare R2, or other S3-compatible service diff --git a/CLAUDE.md b/CLAUDE.md index 5eae5a3..523ced0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -332,14 +332,20 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail - `ensureAgent()` ensures Postgres checkpointer is initialized before agent creation - MCP servers queried from database on each agent creation for dynamic tool loading -- Supports OpenAI/Google/Anthropic models via `AgentConfigOptions` -- **Default model is `anthropic` / `claude-haiku-4-5`, defined in THREE places that must stay in - sync**: `DEFAULT_MODEL_PROVIDER`/`DEFAULT_MODEL_NAME` (`src/lib/agent/util.ts`, server), - `UISettingsContext` (client initial state), and the provider-switch map in - `ModelConfiguration.tsx`. The UI sends `provider`/`model` as query params on every request, so - the **client default wins** — changing only the server constant has no effect on the app. - Existing users keep their `localStorage` choice (`agent_model_settings`); a new default only - applies to fresh browsers. +- **The owner picks the provider and model in the app; nothing is inferred.** Supported: + `google`, `openai`, `anthropic`, `openai-compatible` (any OpenAI chat-completions endpoint — + Ollama, vLLM, Groq, OpenRouter, DeepSeek). There is no default pair and no env var for the + choice: with nothing stored the app is unconfigured, and `/` says so instead of guessing. +- **Stored in the `config` table but deliberately NOT in `src/lib/config/catalog.ts`.** That + catalog is the allowlist gating `set_config`, so keys absent from it are invisible and + unwritable to the agent — Cameron cannot read or change which model it runs on. + `src/lib/agent/modelSettings.ts` owns read/write; `readModelSettings()` returning `null` is the + single definition of "unconfigured". +- **API keys stay in env and are never read for readiness.** Settings names the variable each + provider needs and never inspects it; a bad key surfaces as a failed message, not a pre-flight + check. Keys and the compatible endpoint's base URL are never returned by `/api/agent/config`. +- `buildAgent` uses an explicit `cfg.provider` + `cfg.model` when given (the eval path), else the + stored settings. ### API Route Patterns diff --git a/eval/config.mts b/eval/config.mts index 8489ffe..34353aa 100644 --- a/eval/config.mts +++ b/eval/config.mts @@ -12,7 +12,7 @@ /** Model under test. `EVAL_MODEL=claude-sonnet-5 pnpm eval` to compare without editing code. */ export const MODEL = { provider: "anthropic", - /** Keep in sync with DEFAULT_MODEL_NAME (src/lib/agent/util.ts) — evals should test what ships. */ + /** Pinned here: the app's model is owner-configured, so evals name their own. */ name: process.env.EVAL_MODEL ?? "claude-haiku-4-5", } as const; diff --git a/eval/simulatedUser.mts b/eval/simulatedUser.mts index f301629..2354fe6 100644 --- a/eval/simulatedUser.mts +++ b/eval/simulatedUser.mts @@ -1,5 +1,5 @@ import { createLLMSimulatedUser, runMultiturnSimulation } from "openevals"; -import { createChatModel } from "../src/lib/agent/util.ts"; +import { createChatModel } from "../src/lib/agent/models.ts"; import { DEFAULT_MAX_TURNS, SIMULATOR_MODEL } from "./config.mts"; import { buildUserPrompt, CANNOT_ANSWER, DONE } from "./simulatedUser.prompt.mts"; import type { ConversationTurn, Inconclusive, SimulatedUser } from "./types.mts"; diff --git a/eval/tsconfig.json b/eval/tsconfig.json index 334684d..07310dc 100644 --- a/eval/tsconfig.json +++ b/eval/tsconfig.json @@ -11,12 +11,13 @@ "allowImportingTsExtensions": true, "types": ["node"] }, - // `util.ts` is here for `createChatModel`, which the simulated user reuses. Anything new pulled + // `models.ts` is here for `createChatModel`, which the simulated user reuses. Anything new pulled // in from src/ must be added, or typecheck silently stops covering it. "include": [ "**/*.mts", "../src/lib/agent/capabilities.ts", "../src/lib/agent/util.ts", + "../src/lib/agent/models.ts", "../src/types/**/*.ts" ], "exclude": ["../node_modules"] diff --git a/src/app/api/agent/config/route.ts b/src/app/api/agent/config/route.ts new file mode 100644 index 0000000..89f9ef7 --- /dev/null +++ b/src/app/api/agent/config/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PROVIDERS } from "@/lib/config/modelSettings"; +import { + ModelSettingsError, + readModelSettings, + writeModelSettings, +} from "@/lib/agent/modelSettings"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// Public response: never expose API keys or their values, only which variable each provider reads. +export async function GET() { + const settings = await readModelSettings(); + return NextResponse.json({ + configured: settings !== null, + provider: settings?.provider ?? null, + model: settings?.model ?? null, + baseUrl: settings?.baseUrl ?? null, + providers: PROVIDERS, + }); +} + +export async function PUT(req: NextRequest) { + let body: { provider?: string; model?: string; baseUrl?: string | null }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + try { + const saved = await writeModelSettings({ + provider: body.provider ?? "", + model: body.model ?? "", + baseUrl: body.baseUrl ?? null, + }); + return NextResponse.json({ configured: true, ...saved }); + } catch (error) { + if (error instanceof ModelSettingsError) { + return NextResponse.json({ error: error.message, field: error.field }, { status: 400 }); + } + throw error; + } +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 81f5e90..e424a09 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,7 +1,6 @@ "use client"; import { Suspense } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { UISettingsProvider } from "@/contexts/UISettingsContext"; import { OAuthToast } from "@/components/OAuthToast"; const queryClient = new QueryClient({ @@ -15,12 +14,10 @@ const queryClient = new QueryClient({ export function Providers({ children }: { children: React.ReactNode }) { return ( - - - - - {children} - + + + + {children} ); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..2d3d86d --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,29 @@ +import { BackToChat } from "@/components/BackToChat"; +import { ModelSettingsForm } from "@/components/settings/ModelSettingsForm"; + +export const metadata = { title: "Settings · Cameron AI" }; + +export default function SettingsPage() { + return ( +
+
+ + +

Settings

+

+ Cameron runs on the model you choose. Nothing is assumed — pick a provider and a model + here, and keep the API key in your own environment. +

+ +
+

+ Model +

+
+ +
+
+
+
+ ); +} diff --git a/src/components/MessageInput.tsx b/src/components/MessageInput.tsx index ad1fd0b..ab8e94a 100644 --- a/src/components/MessageInput.tsx +++ b/src/components/MessageInput.tsx @@ -2,8 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import { Button } from "./ui/button"; import { ArrowUp, Loader2, Paperclip, X, ChevronDown } from "lucide-react"; import { MessageOptions, FileAttachment } from "@/types/message"; -import { ModelConfiguration } from "./ModelConfiguration"; -import { useUISettings } from "@/contexts/UISettingsContext"; +import { useModelSettings } from "@/hooks/useModelSettings"; import { MAX_ATTACHMENTS } from "@/lib/storage/validation"; interface MessageInputProps { @@ -23,7 +22,11 @@ export const MessageInput = ({ const [attachments, setAttachments] = useState([]); const [isUploading, setIsUploading] = useState(false); - const { provider, setProvider, model, setModel } = useUISettings(); + const { settings, save, isSaving } = useModelSettings(); + const model = settings?.model ?? ""; + const [modelDraft, setModelDraft] = useState(""); + + useEffect(() => setModelDraft(model), [model]); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -112,13 +115,21 @@ export const MessageInput = ({ setAttachments((prev) => prev.filter((att) => att.key !== key)); }; + const saveModel = async () => { + if (!settings?.provider || !modelDraft.trim() || modelDraft === model) return; + await save({ + provider: settings.provider, + model: modelDraft.trim(), + baseUrl: settings.baseUrl, + }); + setModelOpen(false); + }; + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if ((!message.trim() && attachments.length === 0) || isLoading) return; await onSendMessage(message, { - model, - provider, tools: [], attachments: attachments.length > 0 ? attachments : undefined, }); @@ -216,12 +227,41 @@ export const MessageInput = ({ {modelOpen && (
- + MODEL + + setModelDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void saveModel(); + } + }} + placeholder="Enter model name" + className="border-border bg-background focus:border-brand focus:ring-brand mt-1.5 w-full rounded-md border px-3 py-1.5 font-mono text-sm focus:ring-1 focus:outline-none" /> +
+ + {settings?.provider ?? "no provider"} + + +
+ + Change provider in Settings → +
)} diff --git a/src/components/ModelConfiguration.tsx b/src/components/ModelConfiguration.tsx deleted file mode 100644 index 93f9a05..0000000 --- a/src/components/ModelConfiguration.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState } from "react"; -import { BrainCog } from "lucide-react"; -import Image from "next/image"; - -interface ModelConfigurationProps { - provider: string; - setProvider: (provider: string) => void; - model: string; - setModel: (model: string) => void; -} - -const PROVIDER_DEFAULT_MODEL: Record = { - google: "gemini-3-flash-preview", - openai: "gpt-4o", - anthropic: "claude-haiku-4-5", -}; - -export const ModelConfiguration = ({ - provider, - setProvider, - model, - setModel, -}: ModelConfigurationProps) => { - const [imgError, setImgError] = useState(false); - - return ( -
-
- -
- - {!imgError && ( - {provider} setImgError(true)} - /> - )} - {imgError && } - - -
-
- -
- - setModel(e.target.value)} - placeholder="Enter model name" - className="border-border bg-background focus:border-brand focus:ring-brand w-full rounded-md border px-3 py-1.5 font-mono text-sm focus:ring-1 focus:outline-none" - /> -
-
- ); -}; diff --git a/src/components/NotConfiguredNotice.tsx b/src/components/NotConfiguredNotice.tsx new file mode 100644 index 0000000..9795773 --- /dev/null +++ b/src/components/NotConfiguredNotice.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { SlidersHorizontal } from "lucide-react"; + +export const NotConfiguredNotice = () => ( +
+

No model provider configured

+

+ Cameron needs a provider and model before it can answer. Your existing conversations are still + here to read. +

+ + + Open Settings + +
+); diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 7c638ac..54aac30 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutGrid, Plug } from "lucide-react"; +import { LayoutGrid, Plug, Settings } from "lucide-react"; import { RETURN_TO_KEY } from "./BackToChat"; /** @@ -35,6 +35,25 @@ export const SidebarNav = ({ onOpenMCPConfig }: { onOpenMCPConfig: () => void }) capabilities + { + try { + if (pathname !== "/settings") sessionStorage.setItem(RETURN_TO_KEY, pathname); + } catch { + // Blocked storage: BackToChat falls back to `/`. + } + }} + className={`${item} ${ + pathname === "/settings" + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent hover:text-foreground" + }`} + > + + settings + + - ))} - + {isConfigured ? ( + <> + +
+ {[ + "What did I spend on dining last month?", + "Log a €12 coffee at Blue Bottle", + "Top 5 merchants this year", + ].map((example) => ( + + ))} +
+ + ) : ( + + )} )} diff --git a/src/components/settings/ModelSettingsForm.tsx b/src/components/settings/ModelSettingsForm.tsx new file mode 100644 index 0000000..1044360 --- /dev/null +++ b/src/components/settings/ModelSettingsForm.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, Loader2 } from "lucide-react"; +import { useModelSettings } from "@/hooks/useModelSettings"; +import type { ProviderInfo } from "@/services/chatService"; + +const field = + "border-border bg-background focus:border-brand focus:ring-brand w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none"; +const label = "text-muted-foreground block font-mono text-[10px] tracking-[0.12em]"; + +export const ModelSettingsForm = () => { + const { settings, isLoading, save, isSaving, saveError } = useModelSettings(); + + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [saved, setSaved] = useState(false); + const seeded = useRef(false); + + // Seed once: a refetch after saving would otherwise overwrite what the owner is editing. + useEffect(() => { + if (!settings || seeded.current) return; + seeded.current = true; + setProvider(settings.provider ?? ""); + setModel(settings.model ?? ""); + setBaseUrl(settings.baseUrl ?? ""); + }, [settings]); + + const providers: ProviderInfo[] = settings?.providers ?? []; + const selected = providers.find((p) => p.id === provider); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaved(false); + try { + await save({ provider, model, baseUrl: baseUrl || null }); + setSaved(true); + } catch { + // Surfaced through saveError. + } + }; + + if (isLoading) { + return

Loading…

; + } + + return ( +
+
+ + +
+ +
+ + { + setModel(e.target.value); + setSaved(false); + }} + placeholder="e.g. claude-haiku-4-5" + className={`${field} font-mono`} + /> +
+ + {selected?.requiresBaseUrl && ( +
+ + { + setBaseUrl(e.target.value); + setSaved(false); + }} + placeholder="http://localhost:11434/v1" + className={`${field} font-mono`} + /> +
+ )} + + {selected && ( +
+

{selected.envNote}

+

+ Set the API key as an environment variable — Cameron never stores it: +

+
+            {selected.apiKeyEnvVar}=your_key_here
+            {!selected.apiKeyRequired && "   # optional for local endpoints"}
+          
+

+ Add it to .env.local and restart the server. If a + message fails, check this key and the base URL. +

+
+ )} + + {saveError &&

{saveError}

} + + {saved && !isSaving && ( +
+ +

+ Settings saved — Cameron now uses {model}. +

+
+ )} + +
+ +
+
+ ); +}; diff --git a/src/contexts/UISettingsContext.tsx b/src/contexts/UISettingsContext.tsx deleted file mode 100644 index 1e11a51..0000000 --- a/src/contexts/UISettingsContext.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { createContext, useContext, useEffect, useState, ReactNode } from "react"; - -const STORAGE_KEY = "agent_model_settings"; - -function loadSettings(): Record { - if (typeof window === "undefined") return {}; - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); - } catch { - return {}; - } -} - -function saveSetting(key: string, value: string | boolean) { - if (typeof window === "undefined") return; - try { - const current = loadSettings(); - localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, [key]: value })); - } catch {} -} - -interface UISettingsContextType { - provider: string; - setProvider: (provider: string) => void; - model: string; - setModel: (model: string) => void; -} - -const UISettingsContext = createContext(undefined); - -interface UISettingsProviderProps { - children: ReactNode; -} - -export const UISettingsProvider = ({ children }: UISettingsProviderProps) => { - // Defaults must match DEFAULT_MODEL_PROVIDER/NAME in lib/agent/util.ts — these are sent as - // query params on every request, so they override the server's default. - const [provider, setProviderState] = useState("anthropic"); - const [model, setModelState] = useState("claude-haiku-4-5"); - - useEffect(() => { - const saved = loadSettings(); - if (typeof saved.provider === "string") setProviderState(saved.provider); - if (typeof saved.model === "string") setModelState(saved.model); - }, []); - - const setProvider = (v: string) => { - setProviderState(v); - saveSetting("provider", v); - }; - const setModel = (v: string) => { - setModelState(v); - saveSetting("model", v); - }; - - return ( - - {children} - - ); -}; - -export const useUISettings = () => { - const context = useContext(UISettingsContext); - if (context === undefined) { - throw new Error("useUISettings must be used within a UISettingsProvider"); - } - return context; -}; diff --git a/src/hooks/useModelSettings.ts b/src/hooks/useModelSettings.ts new file mode 100644 index 0000000..c4b7510 --- /dev/null +++ b/src/hooks/useModelSettings.ts @@ -0,0 +1,32 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchAgentConfig, + saveModelSettings, + type ModelSettingsInput, +} from "@/services/chatService"; + +const QUERY_KEY = ["model-settings"]; + +export function useModelSettings() { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: fetchAgentConfig, + staleTime: 30000, + refetchOnWindowFocus: false, + }); + + const mutation = useMutation({ + mutationFn: (input: ModelSettingsInput) => saveModelSettings(input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: QUERY_KEY }), + }); + + return { + settings: query.data, + isLoading: query.isLoading, + save: mutation.mutateAsync, + isSaving: mutation.isPending, + saveError: mutation.error instanceof Error ? mutation.error.message : null, + }; +} diff --git a/src/lib/agent/approvalGate.test.ts b/src/lib/agent/approvalGate.test.ts index cc5f556..e529057 100644 --- a/src/lib/agent/approvalGate.test.ts +++ b/src/lib/agent/approvalGate.test.ts @@ -55,7 +55,8 @@ describe("the approval gate is not reachable from a request", () => { it("no UI surface offers a toggle", async () => { for (const file of [ "../../components/MessageInput.tsx", - "../../contexts/UISettingsContext.tsx", + "../../components/settings/ModelSettingsForm.tsx", + "../../app/settings/page.tsx", ]) { const src = await read(file); expect(src, file).not.toContain("approveAllTools"); diff --git a/src/lib/agent/index.ts b/src/lib/agent/index.ts index 82f2578..21ab7a2 100644 --- a/src/lib/agent/index.ts +++ b/src/lib/agent/index.ts @@ -1,13 +1,9 @@ import { buildSystemPrompt } from "./prompt"; import { postgresCheckpointer, setupCheckpointer } from "./memory"; import type { DynamicTool, StructuredToolInterface } from "@langchain/core/tools"; -import { - AgentConfigOptions, - createChatModel, - DEFAULT_MODEL_NAME, - DEFAULT_MODEL_PROVIDER, - sanitizeTool, -} from "./util"; +import { AgentConfigOptions, sanitizeTool } from "./util"; +import { createChatModel, needsSchemaSanitizing } from "./models"; +import { ModelNotConfiguredError, readModelSettings } from "./modelSettings"; import type { DynamicStructuredTool } from "@langchain/core/tools"; import { getMCPTools } from "./mcp"; import { financeTools } from "./tools/finance"; @@ -28,10 +24,21 @@ import { MUTATING_TOOL_NAMES } from "./capabilities"; * @returns */ async function buildAgent(cfg?: AgentConfigOptions) { - // Resolve model/provider from cfg or defaults. - const provider = cfg?.provider || DEFAULT_MODEL_PROVIDER; - const modelName = cfg?.model || DEFAULT_MODEL_NAME; - const llm = createChatModel({ provider, model: modelName, temperature: 1 }); + // An explicit pair (the eval harness) bypasses the stored settings entirely. + let provider: string; + let modelName: string; + let baseUrl: string | null = null; + if (cfg?.provider && cfg?.model) { + provider = cfg.provider; + modelName = cfg.model; + } else { + const stored = await readModelSettings(); + if (!stored) throw new ModelNotConfiguredError(); + provider = stored.provider; + modelName = stored.model; + baseUrl = stored.baseUrl; + } + const llm = createChatModel({ provider, model: modelName, temperature: 1, baseUrl }); // Built-in finance tools are registered here (server-side) so they are always present and // cannot be omitted by the client. MCP tools are loaded dynamically; per-request config tools @@ -40,10 +47,7 @@ async function buildAgent(cfg?: AgentConfigOptions) { // Per-request tools supplied by the caller — not ./tools/config, which is `settingsTools`. const configTools = (cfg?.tools || []) as StructuredToolInterface[]; - // Tool definitions stay provider-agnostic (plain Zod). Google Gemini's function-calling API is - // the outlier — it rejects standard JSON Schema keywords (exclusiveMinimum, format, $defs, …) that - // Zod emits. So we sanitize built-in tool schemas ONLY when the active provider is Google; other - // providers (Anthropic, OpenAI) accept the schemas as-is. + // Some function-calling APIs reject JSON Schema keywords Zod emits (format, $defs, …). const builtin = [ ...financeTools, ...csvImportTools, @@ -53,7 +57,7 @@ async function buildAgent(cfg?: AgentConfigOptions) { ...skillTools, ...chartTools, ]; - const builtinTools = (provider === "google" + const builtinTools = (needsSchemaSanitizing(provider) ? builtin.map((t) => sanitizeTool(t as unknown as DynamicStructuredTool)) : builtin) as unknown as StructuredToolInterface[]; const allTools = [...builtinTools, ...configTools, ...mcpTools] as DynamicTool[]; diff --git a/src/lib/agent/modelSettings.ts b/src/lib/agent/modelSettings.ts new file mode 100644 index 0000000..b4bfce1 --- /dev/null +++ b/src/lib/agent/modelSettings.ts @@ -0,0 +1,79 @@ +import * as configRepo from "@/lib/repositories/configRepository"; +import { + MODEL_NAME_KEY, + MODEL_PROVIDER_KEY, + OPENAI_COMPATIBLE_BASE_URL_KEY, + parseBaseUrl, + parseModelName, + parseProvider, + providerRequiresBaseUrl, + type ProviderId, +} from "@/lib/config/modelSettings"; + +export interface ModelSettings { + provider: ProviderId; + model: string; + baseUrl: string | null; +} + +export class ModelSettingsError extends Error { + readonly field: string; + constructor(field: string, message: string) { + super(message); + this.name = "ModelSettingsError"; + this.field = field; + } +} + +export class ModelNotConfiguredError extends Error { + constructor() { + super("No model provider is configured. Choose one in Settings."); + this.name = "ModelNotConfiguredError"; + } +} + +// These keys are deliberately absent from the agent's config catalog, so `set_config` cannot +// reach them and the agent cannot change which model it runs on. +export async function readModelSettings(): Promise { + const [providerRow, modelRow, baseUrlRow] = await Promise.all([ + configRepo.get(MODEL_PROVIDER_KEY), + configRepo.get(MODEL_NAME_KEY), + configRepo.get(OPENAI_COMPATIBLE_BASE_URL_KEY), + ]); + + const provider = parseProvider(providerRow?.value ?? ""); + if (!provider.ok) return null; + + const model = parseModelName(modelRow?.value ?? ""); + if (!model.ok) return null; + + const baseUrl = baseUrlRow?.value ?? null; + if (providerRequiresBaseUrl(provider.value) && !baseUrl) return null; + + return { provider: provider.value, model: model.value, baseUrl }; +} + +export async function writeModelSettings(input: { + provider: string; + model: string; + baseUrl?: string | null; +}): Promise { + const provider = parseProvider(input.provider); + if (!provider.ok) throw new ModelSettingsError("provider", provider.error); + + const model = parseModelName(input.model); + if (!model.ok) throw new ModelSettingsError("model", model.error); + + let baseUrl: string | null = null; + if (providerRequiresBaseUrl(provider.value)) { + const parsed = parseBaseUrl(input.baseUrl ?? ""); + if (!parsed.ok) throw new ModelSettingsError("baseUrl", parsed.error); + baseUrl = parsed.value; + } + + await configRepo.set(MODEL_PROVIDER_KEY, provider.value); + await configRepo.set(MODEL_NAME_KEY, model.value); + if (baseUrl) await configRepo.set(OPENAI_COMPATIBLE_BASE_URL_KEY, baseUrl); + + return { provider: provider.value, model: model.value, baseUrl }; +} diff --git a/src/lib/agent/models.test.ts b/src/lib/agent/models.test.ts new file mode 100644 index 0000000..7547ff9 --- /dev/null +++ b/src/lib/agent/models.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createChatModel, needsSchemaSanitizing, OPENAI_COMPATIBLE_PROVIDER } from "./models"; + +const ENV_KEYS = [ + "OPENAI_COMPATIBLE_API_KEY", + "OPENAI_COMPATIBLE_SANITIZE_SCHEMA", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", +] as const; + +const saved: Record = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } +}); + +function apiKeyOf(model: unknown): string | undefined { + return (model as { apiKey?: string }).apiKey; +} + +function modelNameOf(model: unknown): string | undefined { + return (model as { model?: string }).model; +} + +describe("createChatModel", () => { + it("throws on an unknown provider instead of falling back to Google", () => { + expect(() => createChatModel({ provider: "grok", model: "grok-4" })).toThrow( + /Unknown model provider/, + ); + }); + + it("throws when no provider is given rather than defaulting to one", () => { + expect(() => createChatModel({ provider: "", model: "some-model" })).toThrow( + /Unknown model provider/, + ); + }); + + it("builds the three built-in providers", () => { + process.env.OPENAI_API_KEY = "sk-test"; + process.env.ANTHROPIC_API_KEY = "sk-ant-test"; + process.env.GOOGLE_API_KEY = "goog-test"; + expect(createChatModel({ provider: "openai", model: "gpt-4o" })).toBeDefined(); + expect(createChatModel({ provider: "anthropic", model: "claude-haiku-4-5" })).toBeDefined(); + expect(createChatModel({ provider: "google", model: "gemini-3-flash-preview" })).toBeDefined(); + }); + + describe(OPENAI_COMPATIBLE_PROVIDER, () => { + it("requires a base URL", () => { + expect(() => + createChatModel({ provider: OPENAI_COMPATIBLE_PROVIDER, model: "qwen3" }), + ).toThrow(/requires a base URL/); + }); + + it("requires a model name", () => { + expect(() => + createChatModel({ + provider: OPENAI_COMPATIBLE_PROVIDER, + model: "", + baseUrl: "http://localhost:11434/v1", + }), + ).toThrow(/requires a model name/); + }); + + it("uses the base URL and model it is given", () => { + const llm = createChatModel({ + provider: OPENAI_COMPATIBLE_PROVIDER, + model: "qwen3:32b", + baseUrl: "http://localhost:11434/v1", + }); + expect(modelNameOf(llm)).toBe("qwen3:32b"); + }); + + it("never sends OPENAI_API_KEY to a third-party endpoint", () => { + process.env.OPENAI_API_KEY = "sk-real-openai-key"; + const llm = createChatModel({ + provider: OPENAI_COMPATIBLE_PROVIDER, + model: "some-model", + baseUrl: "https://api.example.com/v1", + }); + expect(apiKeyOf(llm)).not.toBe("sk-real-openai-key"); + }); + + it("uses OPENAI_COMPATIBLE_API_KEY when set", () => { + process.env.OPENAI_COMPATIBLE_API_KEY = "gsk-compatible"; + const llm = createChatModel({ + provider: OPENAI_COMPATIBLE_PROVIDER, + model: "some-model", + baseUrl: "https://api.example.com/v1", + }); + expect(apiKeyOf(llm)).toBe("gsk-compatible"); + }); + }); +}); + +describe("needsSchemaSanitizing", () => { + it("always sanitizes for Google and never for the other built-ins", () => { + expect(needsSchemaSanitizing("google")).toBe(true); + expect(needsSchemaSanitizing("openai")).toBe(false); + expect(needsSchemaSanitizing("anthropic")).toBe(false); + }); + + it("is opt-in for the compatible provider", () => { + expect(needsSchemaSanitizing(OPENAI_COMPATIBLE_PROVIDER)).toBe(false); + process.env.OPENAI_COMPATIBLE_SANITIZE_SCHEMA = "true"; + expect(needsSchemaSanitizing(OPENAI_COMPATIBLE_PROVIDER)).toBe(true); + }); +}); diff --git a/src/lib/agent/models.ts b/src/lib/agent/models.ts new file mode 100644 index 0000000..3b51ee2 --- /dev/null +++ b/src/lib/agent/models.ts @@ -0,0 +1,73 @@ +import { ChatOpenAI } from "@langchain/openai"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import { OPENAI_COMPATIBLE_PROVIDER, PROVIDERS } from "@/lib/config/modelSettings"; + +export { OPENAI_COMPATIBLE_PROVIDER }; + +export interface CreateChatModelOptions { + provider: string; + model: string; + temperature?: number; + baseUrl?: string | null; +} + +const NO_API_KEY_NEEDED = "not-needed"; + +export function createChatModel({ + provider, + model, + temperature = 1, + baseUrl, +}: CreateChatModelOptions): BaseChatModel { + switch (provider) { + case "openai": + return new ChatOpenAI({ model, temperature }); + case "anthropic": + return new ChatAnthropic({ model, temperature }); + case "google": + return new ChatGoogleGenerativeAI({ model, temperature }); + case OPENAI_COMPATIBLE_PROVIDER: + return createOpenAICompatibleModel({ model, temperature, baseUrl }); + default: + throw new Error( + `Unknown model provider "${provider}". Expected one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`, + ); + } +} + +function createOpenAICompatibleModel({ + model, + temperature, + baseUrl, +}: { + model: string; + temperature: number; + baseUrl?: string | null; +}): BaseChatModel { + if (!baseUrl) { + throw new Error( + `Provider "${OPENAI_COMPATIBLE_PROVIDER}" requires a base URL. Set one in Settings.`, + ); + } + if (!model) { + throw new Error(`Provider "${OPENAI_COMPATIBLE_PROVIDER}" requires a model name.`); + } + + return new ChatOpenAI({ + model, + temperature, + // Explicit: left undefined, ChatOpenAI would send OPENAI_API_KEY to this third-party URL. + apiKey: process.env.OPENAI_COMPATIBLE_API_KEY || NO_API_KEY_NEEDED, + configuration: { baseURL: baseUrl }, + }); +} + +export function needsSchemaSanitizing(provider: string): boolean { + if (provider === "google") return true; + if (provider === OPENAI_COMPATIBLE_PROVIDER) { + return process.env.OPENAI_COMPATIBLE_SANITIZE_SCHEMA === "true"; + } + return false; +} diff --git a/src/lib/agent/util.ts b/src/lib/agent/util.ts index 9c9acce..1186b34 100644 --- a/src/lib/agent/util.ts +++ b/src/lib/agent/util.ts @@ -1,33 +1,5 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; -import { ChatAnthropic } from "@langchain/anthropic"; -import { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { DynamicStructuredTool } from "@langchain/core/tools"; -export interface CreateChatModelOptions { - provider?: string; // 'openai' | 'google' | 'anthropic' - model: string; - temperature?: number; -} - -/** - * Central factory for creating a chat model based on provider + model name. - */ -export function createChatModel({ - provider = "google", - model, - temperature = 1, -}: CreateChatModelOptions): BaseChatModel { - switch (provider) { - case "openai": - return new ChatOpenAI({ model, temperature }); - case "anthropic": - return new ChatAnthropic({ model, temperature }); - case "google": - default: - return new ChatGoogleGenerativeAI({ model, temperature }); - } -} export interface AgentConfigOptions { model?: string; provider?: string; // 'google' | 'openai' etc. @@ -222,10 +194,3 @@ export function sanitizeTool(tool: DynamicStructuredTool): DynamicStructuredTool return tool; } -/** - * Default model. Keep in sync with the client-side defaults in UISettingsContext and - * ModelConfiguration — the UI sends provider/model on every request, so a mismatch means the - * server default silently never applies. - */ -export const DEFAULT_MODEL_PROVIDER = "anthropic"; -export const DEFAULT_MODEL_NAME = "claude-haiku-4-5"; diff --git a/src/lib/config/modelSettings.test.ts b/src/lib/config/modelSettings.test.ts new file mode 100644 index 0000000..f51145b --- /dev/null +++ b/src/lib/config/modelSettings.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + isSupportedProvider, + OPENAI_COMPATIBLE_PROVIDER, + parseBaseUrl, + parseModelName, + parseProvider, + providerRequiresBaseUrl, +} from "./modelSettings"; + +describe("isSupportedProvider", () => { + it("accepts the four shipped providers and nothing else", () => { + expect(isSupportedProvider("anthropic")).toBe(true); + expect(isSupportedProvider("openai")).toBe(true); + expect(isSupportedProvider("google")).toBe(true); + expect(isSupportedProvider(OPENAI_COMPATIBLE_PROVIDER)).toBe(true); + expect(isSupportedProvider("grok")).toBe(false); + expect(isSupportedProvider("")).toBe(false); + }); +}); + +describe("providerRequiresBaseUrl", () => { + it("is true only for the compatible provider", () => { + expect(providerRequiresBaseUrl(OPENAI_COMPATIBLE_PROVIDER)).toBe(true); + expect(providerRequiresBaseUrl("anthropic")).toBe(false); + expect(providerRequiresBaseUrl("nonsense")).toBe(false); + }); +}); + +describe("parseProvider", () => { + it("accepts a known provider", () => { + expect(parseProvider("anthropic")).toEqual({ ok: true, value: "anthropic" }); + }); + + it("rejects an unknown one and lists the valid ids", () => { + const result = parseProvider("grok"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("anthropic"); + }); +}); + +describe("parseModelName", () => { + it("trims and accepts a name", () => { + expect(parseModelName(" gpt-4o ")).toEqual({ ok: true, value: "gpt-4o" }); + }); + + it("rejects an empty name", () => { + expect(parseModelName(" ").ok).toBe(false); + }); +}); + +describe("parseBaseUrl", () => { + it("accepts an http or https URL and strips trailing slashes", () => { + expect(parseBaseUrl("http://localhost:11434/v1/")).toEqual({ + ok: true, + value: "http://localhost:11434/v1", + }); + expect(parseBaseUrl("https://api.groq.com/openai/v1").ok).toBe(true); + }); + + it("rejects an empty value", () => { + expect(parseBaseUrl("").ok).toBe(false); + }); + + it("rejects a non-URL", () => { + expect(parseBaseUrl("localhost:11434").ok).toBe(false); + }); + + it("rejects a non-http protocol", () => { + expect(parseBaseUrl("ftp://example.com/v1").ok).toBe(false); + }); +}); diff --git a/src/lib/config/modelSettings.ts b/src/lib/config/modelSettings.ts new file mode 100644 index 0000000..f024a29 --- /dev/null +++ b/src/lib/config/modelSettings.ts @@ -0,0 +1,103 @@ +export const MODEL_PROVIDER_KEY = "model_provider"; +export const MODEL_NAME_KEY = "model_name"; +export const OPENAI_COMPATIBLE_BASE_URL_KEY = "openai_compatible_base_url"; + +export const OPENAI_COMPATIBLE_PROVIDER = "openai-compatible"; + +export interface ProviderDef { + id: string; + label: string; + apiKeyEnvVar: string; + apiKeyRequired: boolean; + requiresBaseUrl: boolean; + envNote: string; +} + +export const PROVIDERS = [ + { + id: "anthropic", + label: "Anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + apiKeyRequired: true, + requiresBaseUrl: false, + envNote: "Create a key at console.anthropic.com.", + }, + { + id: "openai", + label: "OpenAI", + apiKeyEnvVar: "OPENAI_API_KEY", + apiKeyRequired: true, + requiresBaseUrl: false, + envNote: "Create a key at platform.openai.com.", + }, + { + id: "google", + label: "Google", + apiKeyEnvVar: "GOOGLE_API_KEY", + apiKeyRequired: true, + requiresBaseUrl: false, + envNote: "Create a key at aistudio.google.com.", + }, + { + id: OPENAI_COMPATIBLE_PROVIDER, + label: "OpenAI Compatible", + apiKeyEnvVar: "OPENAI_COMPATIBLE_API_KEY", + apiKeyRequired: false, + requiresBaseUrl: true, + envNote: + "Any endpoint speaking the OpenAI chat-completions API — Ollama, vLLM, LM Studio, Groq, " + + "OpenRouter, DeepSeek. Local servers usually need no key.", + }, +] as const satisfies readonly ProviderDef[]; + +export type ProviderId = (typeof PROVIDERS)[number]["id"]; + +export function isSupportedProvider(id: string): id is ProviderId { + return PROVIDERS.some((p) => p.id === id); +} + +export function getProviderDef(id: ProviderId): ProviderDef { + return PROVIDERS.find((p) => p.id === id) as ProviderDef; +} + +export function providerRequiresBaseUrl(id: string): boolean { + return isSupportedProvider(id) && getProviderDef(id).requiresBaseUrl; +} + +export type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; + +export function parseProvider(raw: string): ValidationResult { + const trimmed = raw.trim(); + if (!isSupportedProvider(trimmed)) { + return { + ok: false, + error: `"${raw}" is not a supported provider. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`, + }; + } + return { ok: true, value: trimmed }; +} + +export function parseModelName(raw: string): ValidationResult { + const trimmed = raw.trim(); + if (!trimmed) { + return { ok: false, error: "Enter a model name." }; + } + return { ok: true, value: trimmed }; +} + +export function parseBaseUrl(raw: string): ValidationResult { + const trimmed = raw.trim().replace(/\/+$/, ""); + if (!trimmed) { + return { ok: false, error: "Enter the endpoint's base URL (e.g. http://localhost:11434/v1)." }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, error: `"${raw}" is not a valid URL.` }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false, error: "The base URL must start with http:// or https://." }; + } + return { ok: true, value: trimmed }; +} diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 75e04fb..2afc908 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -7,6 +7,7 @@ export interface ChatServiceConfig { chat?: string; stream?: string; threads?: string; + config?: string; }; headers?: Record; } @@ -18,6 +19,7 @@ const config: ChatServiceConfig = { chat: "/chat", stream: "/stream", threads: "/threads", + config: "/config", }, }; @@ -36,14 +38,55 @@ export async function fetchMessageHistory(threadId: string): Promise { + const response = await fetch(getUrl("config"), { headers: config.headers }); + if (!response.ok) { + throw new Error("Failed to load agent config"); + } + return (await response.json()) as AgentConfig; +} + +export async function saveModelSettings(input: ModelSettingsInput): Promise { + const response = await fetch(getUrl("config"), { + method: "PUT", + headers: { "Content-Type": "application/json", ...config.headers }, + body: JSON.stringify(input), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || "Failed to save model settings"); + } +} + export function createMessageStream( threadId: string, message: string, opts?: MessageOptions, ): EventSource { const params = new URLSearchParams({ content: message, threadId }); - if (opts?.model) params.set("model", opts.model); - if (opts?.provider) params.set("provider", opts.provider); if (opts?.tools?.length) params.set("tools", opts.tools.join(",")); if (opts?.allowTool) params.set("allowTool", opts.allowTool); if (opts?.attachments && opts.attachments.length > 0) {