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.
+
+
+
+
+
+ );
+}
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 && (
)}
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 (
-
-
-
- PROVIDER
-
-
-
- {!imgError && (
- setImgError(true)}
- />
- )}
- {imgError && }
-
- {
- const newProvider = e.target.value;
- setImgError(false);
- setProvider(newProvider);
- setModel(PROVIDER_DEFAULT_MODEL[newProvider] ?? "");
- }}
- className="border-border bg-background focus:border-brand focus:ring-brand flex-1 rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none"
- >
- Google
- OpenAI
- Anthropic
-
-
-
-
-
-
- MODEL
-
- 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
+
+
{
const { messages, isLoadingHistory, isSending, sendMessage, approveToolExecution } =
useChatThread({ threadId: activeId });
const { createThread } = useThreads();
- const { provider, model } = useUISettings();
+ const { settings } = useModelSettings();
+ const isConfigured = settings?.configured ?? true;
// Guards against a double-send racing two thread creations before the URL has been adopted.
const creatingRef = useRef | null>(null);
@@ -74,7 +76,11 @@ export const Thread = ({ threadId }: ThreadProps) => {
-
+ {isConfigured ? (
+
+ ) : (
+
+ )}
@@ -94,23 +100,29 @@ export const Thread = ({ threadId }: ThreadProps) => {
written without your approval, and your data never leaves this machine.
-
-
- {[
- "What did I spend on dining last month?",
- "Log a €12 coffee at Blue Bottle",
- "Top 5 merchants this year",
- ].map((example) => (
- handleSendMessage(example, { provider, model, tools: [] })}
- className="border-border text-muted-foreground hover:border-brand hover:text-foreground cursor-pointer rounded-full border px-3 py-1.5 text-[13px] transition-colors"
- >
- {example}
-
- ))}
-
+ {isConfigured ? (
+ <>
+
+
+ {[
+ "What did I spend on dining last month?",
+ "Log a €12 coffee at Blue Bottle",
+ "Top 5 merchants this year",
+ ].map((example) => (
+ handleSendMessage(example, { tools: [] })}
+ className="border-border text-muted-foreground hover:border-brand hover:text-foreground cursor-pointer rounded-full border px-3 py-1.5 text-[13px] transition-colors"
+ >
+ {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 (
+
+ );
+};
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) {