Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion eval/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion eval/simulatedUser.mts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
3 changes: 2 additions & 1 deletion eval/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
45 changes: 45 additions & 0 deletions src/app/api/agent/config/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 4 additions & 7 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -15,12 +14,10 @@ const queryClient = new QueryClient({
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<UISettingsProvider>
<Suspense fallback={null}>
<OAuthToast />
</Suspense>
{children}
</UISettingsProvider>
<Suspense fallback={null}>
<OAuthToast />
</Suspense>
{children}
</QueryClientProvider>
);
}
29 changes: 29 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-muted/40 h-screen overflow-y-auto">
<div className="mx-auto max-w-3xl px-6 py-12">
<BackToChat />

<h1 className="text-foreground text-2xl font-semibold">Settings</h1>
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">
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.
</p>

<section className="mt-10">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
Model
</h2>
<div className="border-border mt-3 rounded-lg border bg-white p-4">
<ModelSettingsForm />
</div>
</section>
</div>
</div>
);
}
60 changes: 50 additions & 10 deletions src/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +22,11 @@ export const MessageInput = ({
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
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<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -216,12 +227,41 @@ export const MessageInput = ({
</button>
{modelOpen && (
<div className="border-border bg-popover absolute bottom-full left-0 z-50 mb-2 w-72 rounded-lg border p-3 shadow-lg">
<ModelConfiguration
provider={provider}
setProvider={setProvider}
model={model}
setModel={setModel}
<label
htmlFor="model-draft"
className="text-muted-foreground block font-mono text-[10px] tracking-[0.12em]"
>
MODEL
</label>
<input
id="model-draft"
value={modelDraft}
onChange={(e) => 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"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-muted-foreground font-mono text-[10px]">
{settings?.provider ?? "no provider"}
</span>
<button
type="button"
onClick={() => void saveModel()}
disabled={isSaving || !modelDraft.trim() || modelDraft === model}
className="text-brand cursor-pointer text-xs disabled:opacity-50"
>
save
</button>
</div>
<a href="/settings" className="text-muted-foreground mt-2 block text-[11px]">
Change provider in Settings →
</a>
</div>
)}
</div>
Expand Down
76 changes: 0 additions & 76 deletions src/components/ModelConfiguration.tsx

This file was deleted.

19 changes: 19 additions & 0 deletions src/components/NotConfiguredNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Link from "next/link";
import { SlidersHorizontal } from "lucide-react";

export const NotConfiguredNotice = () => (
<div className="border-border bg-card mx-auto max-w-3xl rounded-xl border p-4">
<p className="text-foreground text-sm font-medium">No model provider configured</p>
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
Cameron needs a provider and model before it can answer. Your existing conversations are still
here to read.
</p>
<Link
href="/settings"
className="text-brand mt-3 inline-flex items-center gap-1.5 text-sm hover:underline"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
Open Settings
</Link>
</div>
);
Loading