From 6fba19bc98cefb5bf2b0dac3ad859b74756ed2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:36:13 +0000 Subject: [PATCH] feat: add Ollama as a third AI provider for local LLM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full Ollama integration so users can run Siftly with local models (llama3.1, mistral, etc.) — completely free, no API keys needed. Changes: - lib/settings.ts: New AIProvider type ('anthropic'|'openai'|'ollama'), getOllamaModel(), getOllamaBaseUrl() with caching - lib/ai-client.ts: resolveOllamaClient() using OpenAI-compatible API, OpenAIAIClient now accepts provider parameter - app/api/settings/route.ts: GET/POST handlers for ollamaModel & ollamaBaseUrl - app/api/settings/test/route.ts: Ollama connection test with friendly errors - app/api/settings/cli-status/route.ts: Ollama availability check via /api/tags - app/settings/page.tsx: Three-tab provider toggle (Anthropic/OpenAI/Ollama), OllamaSettingsPanel with auto-detected models dropdown, base URL config, and connection test button - app/api/categorize/route.ts, search/ai/route.ts, analyze/images/route.ts, lib/categorizer.ts: Handle 'ollama' provider in API key resolution - .env.example: Document OLLAMA_BASE_URL - CLAUDE.md: Ollama setup instructions https://claude.ai/code/session_01HFSAKuoayERDciumSrRUyt --- .env.example | 4 + CLAUDE.md | 24 ++- app/api/analyze/images/route.ts | 4 +- app/api/categorize/route.ts | 22 ++- app/api/search/ai/route.ts | 6 +- app/api/settings/cli-status/route.ts | 25 ++- app/api/settings/route.ts | 40 ++++- app/api/settings/test/route.ts | 25 +++ app/settings/page.tsx | 246 ++++++++++++++++++++++++++- lib/ai-client.ts | 25 ++- lib/categorizer.ts | 6 +- lib/settings.ts | 47 ++++- 12 files changed, 430 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 61d7e03..8c45002 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ DATABASE_URL="file:./prisma/dev.db" # Optional: custom API base URL (proxy or local model server) # ANTHROPIC_BASE_URL= +# ── Ollama (local LLMs, no API key needed) ────────────────────────── +# Install: https://ollama.com • Start: ollama serve • Pull model: ollama pull llama3.1 +# OLLAMA_BASE_URL=http://localhost:11434 + # ── Access control (optional) ──────────────────────────────────────── # Set BOTH to enable HTTP Basic Auth on the entire app. diff --git a/CLAUDE.md b/CLAUDE.md index 29041ca..d51fe7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,16 +18,26 @@ npx next dev App runs at **http://localhost:3000** -## AI Authentication — No API Key Needed +## AI Providers -If the user is signed into Claude Code CLI, **Siftly uses their Claude subscription automatically**. No API key configuration required. +Siftly supports three AI providers — switch between them in Settings: -How it works: -- `lib/claude-cli-auth.ts` reads the OAuth token from the macOS keychain (`Claude Code-credentials`) -- Uses `authToken` + `anthropic-beta: oauth-2025-04-20` header in the Anthropic SDK +### Ollama (Local LLMs — Free, Private) +1. Install Ollama: https://ollama.com +2. Start the server: `ollama serve` +3. Pull a model: `ollama pull llama3.1` +4. In Siftly Settings, select **Ollama** and pick your model +5. No API key needed — everything runs locally + +### Claude (Anthropic) +If signed into Claude Code CLI, **Siftly uses your Claude subscription automatically**. No API key needed. +- `lib/claude-cli-auth.ts` reads the OAuth token from the macOS keychain - Falls back to: DB-saved API key → `ANTHROPIC_API_KEY` env var → local proxy -To verify it's working, hit: `GET /api/settings/cli-status` +### OpenAI +Set your OpenAI API key in Settings, or use Codex CLI auth. + +To verify provider status: `GET /api/settings/cli-status` ## Key Commands @@ -78,7 +88,7 @@ prisma/schema.prisma # SQLite schema (Bookmark, Category, MediaItem, Setting, I - **Next.js 16** (App Router, TypeScript) - **Prisma 7** + **SQLite** (local, zero setup, FTS5 built in) -- **Anthropic SDK** — vision, tagging, categorization, search +- **Anthropic SDK / OpenAI SDK / Ollama** — vision, tagging, categorization, search - **@xyflow/react** — mindmap graph - **Tailwind CSS v4** diff --git a/app/api/analyze/images/route.ts b/app/api/analyze/images/route.ts index 4366026..bd55d90 100644 --- a/app/api/analyze/images/route.ts +++ b/app/api/analyze/images/route.ts @@ -24,8 +24,8 @@ export async function POST(request: NextRequest): Promise { } const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' - const setting = await prisma.setting.findUnique({ where: { key: keyName } }) + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'ollama' ? null : 'anthropicApiKey' + const setting = keyName ? await prisma.setting.findUnique({ where: { key: keyName } }) : null const dbKey = setting?.value?.trim() let client: AIClient | null = null diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts index 71f6017..2731a35 100644 --- a/app/api/categorize/route.ts +++ b/app/api/categorize/route.ts @@ -111,12 +111,15 @@ export async function POST(request: NextRequest): Promise { if (apiKey && typeof apiKey === 'string' && apiKey.trim() !== '') { const currentProvider = await getProvider() - const keySlot = currentProvider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' - await prisma.setting.upsert({ - where: { key: keySlot }, - update: { value: apiKey.trim() }, - create: { key: keySlot, value: apiKey.trim() }, - }) + // Ollama doesn't use API keys — skip saving + if (currentProvider !== 'ollama') { + const keySlot = currentProvider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' + await prisma.setting.upsert({ + where: { key: keySlot }, + update: { value: apiKey.trim() }, + create: { key: keySlot, value: apiKey.trim() }, + }) + } } globalState.categorizationAbort = false @@ -145,9 +148,10 @@ export async function POST(request: NextRequest): Promise { }) const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' - const dbApiKey = - (await prisma.setting.findUnique({ where: { key: keyName } }))?.value?.trim() || '' + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'ollama' ? null : 'anthropicApiKey' + const dbApiKey = keyName + ? ((await prisma.setting.findUnique({ where: { key: keyName } }))?.value?.trim() || '') + : '' // Ollama doesn't need an API key void (async () => { const counts = { visionTagged: 0, entitiesExtracted: 0, enriched: 0, categorized: 0 } diff --git a/app/api/search/ai/route.ts b/app/api/search/ai/route.ts index 5d62886..8555fde 100644 --- a/app/api/search/ai/route.ts +++ b/app/api/search/ai/route.ts @@ -32,6 +32,7 @@ let _categoriesCacheExpiry = 0 async function getDbApiKey(): Promise { if (_apiKey !== null && Date.now() < _apiKeyExpiry) return _apiKey const provider = await getProvider() + if (provider === 'ollama') { _apiKey = ''; _apiKeyExpiry = Date.now() + 60_000; return '' } const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' const setting = await prisma.setting.findUnique({ where: { key: keyName } }) const fromDb = setting?.value?.trim() ?? '' @@ -350,9 +351,12 @@ Constraints: : { matches: [], explanation: 'No results found.' } } + // Ollama and other providers: skip CLI path for Ollama (it uses SDK directly) // Try CLI first (works with ChatGPT OAuth), then fall back to SDK let cliSucceeded = false - if (provider === 'openai' && await getCodexCliAvailability()) { + if (provider === 'ollama') { + // Ollama always uses the SDK path (OpenAI-compatible), skip CLI + } else if (provider === 'openai' && await getCodexCliAvailability()) { try { const result = await codexPrompt(prompt, { timeoutMs: 90_000 }) if (result.success && result.data) { diff --git a/app/api/settings/cli-status/route.ts b/app/api/settings/cli-status/route.ts index a84d1e9..a6a55b7 100644 --- a/app/api/settings/cli-status/route.ts +++ b/app/api/settings/cli-status/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import prisma from '@/lib/db' import { getCliAuthStatus, getCliAvailability } from '@/lib/claude-cli-auth' import { getCodexCliAuthStatus } from '@/lib/openai-auth' +import { getOllamaBaseUrl } from '@/lib/settings' export async function GET(): Promise { const oauthStatus = getCliAuthStatus() @@ -10,18 +11,40 @@ export async function GET(): Promise { // Read provider directly from DB (not cached) — this endpoint is called // right after the user toggles the provider, so it must be fresh. const providerSetting = await prisma.setting.findUnique({ where: { key: 'aiProvider' } }) - const provider = providerSetting?.value === 'openai' ? 'openai' : 'anthropic' + const val = providerSetting?.value + const provider = val === 'openai' ? 'openai' : val === 'ollama' ? 'ollama' : 'anthropic' // Only check CLI subprocess availability if OAuth credentials exist const cliDirectAvailable = oauthStatus.available && !oauthStatus.expired ? await getCliAvailability() : false + // Check Ollama availability by hitting its API + let ollamaStatus: { available: boolean; error?: string } = { available: false } + if (provider === 'ollama') { + try { + const baseUrl = await getOllamaBaseUrl() + const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const data = await res.json() as { models?: { name: string }[] } + ollamaStatus = { available: true } + if (data.models) { + (ollamaStatus as { available: boolean; models?: string[] }).models = data.models.map(m => m.name) + } + } else { + ollamaStatus = { available: false, error: `HTTP ${res.status}` } + } + } catch (err) { + ollamaStatus = { available: false, error: err instanceof Error ? err.message : 'Connection failed' } + } + } + return NextResponse.json({ ...oauthStatus, cliDirectAvailable, mode: cliDirectAvailable ? 'cli' : oauthStatus.available ? 'oauth' : 'api-key', codex: codexStatus, + ollama: ollamaStatus, provider, }) } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index f06373e..7717601 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -24,12 +24,14 @@ const ALLOWED_OPENAI_MODELS = [ export async function GET(): Promise { try { - const [anthropic, anthropicModel, provider, openai, openaiModel, xClientId, xClientSecret] = await Promise.all([ + const [anthropic, anthropicModel, provider, openai, openaiModel, ollamaModel, ollamaBaseUrl, xClientId, xClientSecret] = await Promise.all([ prisma.setting.findUnique({ where: { key: 'anthropicApiKey' } }), prisma.setting.findUnique({ where: { key: 'anthropicModel' } }), prisma.setting.findUnique({ where: { key: 'aiProvider' } }), prisma.setting.findUnique({ where: { key: 'openaiApiKey' } }), prisma.setting.findUnique({ where: { key: 'openaiModel' } }), + prisma.setting.findUnique({ where: { key: 'ollamaModel' } }), + prisma.setting.findUnique({ where: { key: 'ollamaBaseUrl' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), ]) @@ -42,6 +44,8 @@ export async function GET(): Promise { openaiApiKey: maskKey(openai?.value ?? null), hasOpenaiKey: openai !== null, openaiModel: openaiModel?.value ?? 'gpt-4.1-mini', + ollamaModel: ollamaModel?.value ?? 'llama3.1', + ollamaBaseUrl: ollamaBaseUrl?.value ?? 'http://localhost:11434', xOAuthClientId: maskKey(xClientId?.value ?? null), xOAuthClientSecret: maskKey(xClientSecret?.value ?? null), hasXOAuth: !!xClientId?.value, @@ -62,6 +66,8 @@ export async function POST(request: NextRequest): Promise { provider?: string openaiApiKey?: string openaiModel?: string + ollamaModel?: string + ollamaBaseUrl?: string xOAuthClientId?: string xOAuthClientSecret?: string } = {} @@ -71,11 +77,11 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { anthropicApiKey, anthropicModel, provider, openaiApiKey, openaiModel } = body + const { anthropicApiKey, anthropicModel, provider, openaiApiKey, openaiModel, ollamaModel, ollamaBaseUrl } = body // Save provider if provided if (provider !== undefined) { - if (provider !== 'anthropic' && provider !== 'openai') { + if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'ollama') { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }) } await prisma.setting.upsert({ @@ -115,6 +121,34 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ saved: true }) } + // Save Ollama model if provided (free-form — user can type any model name) + if (ollamaModel !== undefined) { + if (typeof ollamaModel !== 'string' || ollamaModel.trim() === '') { + return NextResponse.json({ error: 'Invalid Ollama model' }, { status: 400 }) + } + await prisma.setting.upsert({ + where: { key: 'ollamaModel' }, + update: { value: ollamaModel.trim() }, + create: { key: 'ollamaModel', value: ollamaModel.trim() }, + }) + invalidateSettingsCache() + return NextResponse.json({ saved: true }) + } + + // Save Ollama base URL if provided + if (ollamaBaseUrl !== undefined) { + if (typeof ollamaBaseUrl !== 'string' || ollamaBaseUrl.trim() === '') { + return NextResponse.json({ error: 'Invalid Ollama base URL' }, { status: 400 }) + } + await prisma.setting.upsert({ + where: { key: 'ollamaBaseUrl' }, + update: { value: ollamaBaseUrl.trim() }, + create: { key: 'ollamaBaseUrl', value: ollamaBaseUrl.trim() }, + }) + invalidateSettingsCache() + return NextResponse.json({ saved: true }) + } + // Save Anthropic key if provided if (anthropicApiKey !== undefined) { if (typeof anthropicApiKey !== 'string' || anthropicApiKey.trim() === '') { diff --git a/app/api/settings/test/route.ts b/app/api/settings/test/route.ts index 84c12af..fab0a51 100644 --- a/app/api/settings/test/route.ts +++ b/app/api/settings/test/route.ts @@ -76,5 +76,30 @@ export async function POST(request: NextRequest): Promise { } } + if (provider === 'ollama') { + try { + const { resolveOllamaClient } = await import('@/lib/ai-client') + const { getOllamaBaseUrl, getOllamaModel } = await import('@/lib/settings') + const baseUrl = await getOllamaBaseUrl() + const model = await getOllamaModel() + const client = await resolveOllamaClient(baseUrl) + + await client.chat.completions.create({ + model, + max_tokens: 5, + messages: [{ role: 'user', content: 'hi' }], + }) + return NextResponse.json({ working: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const friendly = msg.includes('ECONNREFUSED') + ? 'Cannot connect to Ollama — is it running? (ollama serve)' + : msg.includes('model') + ? `Model not found — run: ollama pull ` + : msg.slice(0, 120) + return NextResponse.json({ working: false, error: friendly }) + } + } + return NextResponse.json({ error: 'Unknown provider' }, { status: 400 }) } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f022624..2306fc9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -495,7 +495,9 @@ function CodexCliStatusBox() { ) } -function ProviderToggle({ value, onChange }: { value: 'anthropic' | 'openai'; onChange: (v: 'anthropic' | 'openai') => void }) { +type Provider = 'anthropic' | 'openai' | 'ollama' + +function ProviderToggle({ value, onChange }: { value: Provider; onChange: (v: Provider) => void }) { return (
+
) } +function OllamaSettingsPanel({ onToast }: { onToast: (t: Toast) => void }) { + const [model, setModel] = useState('llama3.1') + const [baseUrl, setBaseUrl] = useState('http://localhost:11434') + const [modelSaved, setModelSaved] = useState(false) + const [urlSaved, setUrlSaved] = useState(false) + const [testState, setTestState] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle') + const [testError, setTestError] = useState('') + const [ollamaStatus, setOllamaStatus] = useState<{ available: boolean; models?: string[]; error?: string } | null>(null) + + useEffect(() => { + fetch('/api/settings') + .then((r) => r.json()) + .then((d: Record) => { + if (d.ollamaModel) setModel(d.ollamaModel as string) + if (d.ollamaBaseUrl) setBaseUrl(d.ollamaBaseUrl as string) + }) + .catch(() => {}) + + fetch('/api/settings/cli-status') + .then((r) => r.json()) + .then((d: { ollama?: { available: boolean; models?: string[]; error?: string } }) => { + setOllamaStatus(d.ollama ?? { available: false }) + }) + .catch(() => setOllamaStatus({ available: false })) + }, []) + + async function saveModel(val: string) { + setModel(val) + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ollamaModel: val }), + }) + if (!res.ok) throw new Error('Failed') + setModelSaved(true) + setTimeout(() => setModelSaved(false), 2000) + } catch { + onToast({ type: 'error', message: 'Failed to save model' }) + } + } + + async function saveBaseUrl() { + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ollamaBaseUrl: baseUrl.trim() }), + }) + if (!res.ok) throw new Error('Failed') + setUrlSaved(true) + setTimeout(() => setUrlSaved(false), 2000) + // Re-check Ollama status after URL change + fetch('/api/settings/cli-status') + .then((r) => r.json()) + .then((d: { ollama?: { available: boolean; models?: string[]; error?: string } }) => { + setOllamaStatus(d.ollama ?? { available: false }) + }) + .catch(() => {}) + } catch { + onToast({ type: 'error', message: 'Failed to save base URL' }) + } + } + + async function handleTest() { + setTestState('testing') + setTestError('') + try { + const res = await fetch('/api/settings/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'ollama' }), + }) + const data = await res.json() as { working: boolean; error?: string } + if (data.working) { + setTestState('ok') + onToast({ type: 'success', message: 'Ollama is working!' }) + } else { + setTestState('fail') + setTestError(data.error ?? 'Connection failed') + } + } catch { + setTestState('fail') + setTestError('Connection error') + } + } + + return ( +
+ {/* Status box */} + {ollamaStatus && ollamaStatus.available ? ( +
+ +
+

+ Ollama is running — no API key needed +

+

+ Connected to {baseUrl}. + {ollamaStatus.models && ollamaStatus.models.length > 0 && ( + <> Available models: {ollamaStatus.models.join(', ')} + )} +

+
+
+ ) : ollamaStatus ? ( +
+ +
+

Ollama not detected

+

+ Make sure Ollama is running: ollama serve + {ollamaStatus.error && ({ollamaStatus.error})} +

+
+
+ ) : null} + + {/* Base URL */} +
+

Server URL

+
+ setBaseUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void saveBaseUrl()} + placeholder="http://localhost:11434" + className="flex-1 px-3.5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 transition-all duration-200 font-mono" + /> + +
+

Default: http://localhost:11434. Change if Ollama runs on another host/port.

+
+ + {/* Model */} +
+
+

Model

+ {modelSaved && ( + + Saved + + )} +
+ {ollamaStatus?.models && ollamaStatus.models.length > 0 ? ( +
+ + +
+ ) : ( + setModel(e.target.value)} + onBlur={() => void saveModel(model)} + onKeyDown={(e) => e.key === 'Enter' && void saveModel(model)} + placeholder="llama3.1" + className="w-full px-3.5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 transition-all duration-200 font-mono" + /> + )} +

+ Pull models with: ollama pull llama3.1 +

+
+ + {/* Test button */} +
+ + {testState === 'ok' && ( + + Working + + )} + {testState === 'fail' && ( + + {testError.slice(0, 50) || 'Failed'} + + )} +
+ +

+ Ollama runs locally — completely free, no API keys needed. Your data never leaves your machine. +

+
+ ) +} + function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) { - const [provider, setProvider] = useState<'anthropic' | 'openai' | null>(null) + const [provider, setProvider] = useState(null) useEffect(() => { fetch('/api/settings') .then((r) => r.json()) .then((d: { provider?: string }) => { - setProvider(d.provider === 'openai' ? 'openai' : 'anthropic') + const p = d.provider + setProvider(p === 'openai' ? 'openai' : p === 'ollama' ? 'ollama' : 'anthropic') }) .catch(() => setProvider('anthropic')) }, []) - async function handleProviderChange(newProvider: 'anthropic' | 'openai') { + async function handleProviderChange(newProvider: Provider) { const prev = provider setProvider(newProvider) try { @@ -544,7 +769,8 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) { body: JSON.stringify({ provider: newProvider }), }) if (!res.ok) throw new Error('Failed to save provider') - onToast({ type: 'success', message: `Switched to ${newProvider === 'openai' ? 'OpenAI' : 'Anthropic'}` }) + const labels: Record = { anthropic: 'Anthropic', openai: 'OpenAI', ollama: 'Ollama' } + onToast({ type: 'success', message: `Switched to ${labels[newProvider]}` }) } catch { setProvider(prev) // revert on failure onToast({ type: 'error', message: 'Failed to save provider preference' }) @@ -598,7 +824,7 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) { - ) : ( + ) : provider === 'openai' ? ( <>
@@ -622,6 +848,8 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) {
+ ) : ( + )}

Keys are stored in plaintext in your local SQLite database (prisma/dev.db). Do not expose the database file.

@@ -756,7 +984,7 @@ function DangerZoneSection({ onToast }: { onToast: (t: Toast) => void }) { const TECH_STACK = [ { label: 'Next.js 15', color: 'bg-zinc-800 text-zinc-300 border-zinc-700' }, { label: 'Prisma + SQLite', color: 'bg-zinc-800 text-zinc-300 border-zinc-700' }, - { label: 'Anthropic / OpenAI', color: 'bg-blue-500/10 text-blue-300 border-blue-500/20' }, + { label: 'Anthropic / OpenAI / Ollama', color: 'bg-blue-500/10 text-blue-300 border-blue-500/20' }, { label: 'React Flow', color: 'bg-zinc-800 text-zinc-300 border-zinc-700' }, { label: 'Tailwind CSS', color: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' }, ] diff --git a/lib/ai-client.ts b/lib/ai-client.ts index ca8135b..2e06d59 100644 --- a/lib/ai-client.ts +++ b/lib/ai-client.ts @@ -2,7 +2,7 @@ import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai' import { resolveAnthropicClient } from './claude-cli-auth' import { resolveOpenAIClient } from './openai-auth' -import { getProvider } from './settings' +import { getProvider, getOllamaBaseUrl, type AIProvider } from './settings' export interface AIContentBlock { type: 'text' | 'image' @@ -20,7 +20,7 @@ export interface AIResponse { } export interface AIClient { - provider: 'anthropic' | 'openai' + provider: AIProvider createMessage(params: { model: string max_tokens: number @@ -65,10 +65,12 @@ export class AnthropicAIClient implements AIClient { } } -// Wrap OpenAI SDK +// Wrap OpenAI SDK (also used for Ollama via OpenAI-compatible API) export class OpenAIAIClient implements AIClient { - provider = 'openai' as const - constructor(private sdk: OpenAI) {} + provider: AIProvider + constructor(private sdk: OpenAI, provider: AIProvider = 'openai') { + this.provider = provider + } async createMessage(params: { model: string; max_tokens: number; messages: AIMessage[] }): Promise { const messages: OpenAI.ChatCompletionMessageParam[] = params.messages.map((m): OpenAI.ChatCompletionMessageParam => { @@ -99,12 +101,25 @@ export class OpenAIAIClient implements AIClient { } } +export async function resolveOllamaClient(baseUrl?: string): Promise { + const ollamaBase = baseUrl ?? await getOllamaBaseUrl() + return new OpenAI({ + baseURL: `${ollamaBase}/v1`, + apiKey: 'ollama', // Ollama doesn't need a real key, but the SDK requires one + }) +} + export async function resolveAIClient(options: { overrideKey?: string dbKey?: string } = {}): Promise { const provider = await getProvider() + if (provider === 'ollama') { + const client = await resolveOllamaClient() + return new OpenAIAIClient(client, 'ollama') + } + if (provider === 'openai') { const client = resolveOpenAIClient(options) return new OpenAIAIClient(client) diff --git a/lib/categorizer.ts b/lib/categorizer.ts index 4cfd377..0746728 100644 --- a/lib/categorizer.ts +++ b/lib/categorizer.ts @@ -399,13 +399,13 @@ export async function categorizeAll( // Resolve auth once — avoids re-resolving inside every batch call const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' - const apiKeySetting = await prisma.setting.findUnique({ where: { key: keyName } }) + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'ollama' ? null : 'anthropicApiKey' + const apiKeySetting = keyName ? await prisma.setting.findUnique({ where: { key: keyName } }) : null let client: AIClient | null = null try { client = await resolveAIClient({ dbKey: apiKeySetting?.value }) } catch { - // CLI might still work — client stays null + // CLI might still work — client stays null (not applicable for Ollama) } // Load ALL categories (default + custom) for the prompt diff --git a/lib/settings.ts b/lib/settings.ts index f1d7810..098191c 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -1,15 +1,23 @@ import prisma from '@/lib/db' +export type AIProvider = 'anthropic' | 'openai' | 'ollama' + // Module-level caches — avoids hundreds of DB roundtrips per pipeline run let _cachedModel: string | null = null let _modelCacheExpiry = 0 -let _cachedProvider: 'anthropic' | 'openai' | null = null +let _cachedProvider: AIProvider | null = null let _providerCacheExpiry = 0 let _cachedOpenAIModel: string | null = null let _openAIModelCacheExpiry = 0 +let _cachedOllamaModel: string | null = null +let _ollamaModelCacheExpiry = 0 + +let _cachedOllamaBaseUrl: string | null = null +let _ollamaBaseUrlCacheExpiry = 0 + const CACHE_TTL = 5 * 60 * 1000 /** @@ -26,10 +34,11 @@ export async function getAnthropicModel(): Promise { /** * Get the active AI provider (cached for 5 minutes). */ -export async function getProvider(): Promise<'anthropic' | 'openai'> { +export async function getProvider(): Promise { if (_cachedProvider && Date.now() < _providerCacheExpiry) return _cachedProvider const setting = await prisma.setting.findUnique({ where: { key: 'aiProvider' } }) - _cachedProvider = setting?.value === 'openai' ? 'openai' : 'anthropic' + const val = setting?.value + _cachedProvider = val === 'openai' ? 'openai' : val === 'ollama' ? 'ollama' : 'anthropic' _providerCacheExpiry = Date.now() + CACHE_TTL return _cachedProvider } @@ -45,12 +54,38 @@ export async function getOpenAIModel(): Promise { return _cachedOpenAIModel } +/** + * Get the configured Ollama model from settings (cached for 5 minutes). + */ +export async function getOllamaModel(): Promise { + if (_cachedOllamaModel && Date.now() < _ollamaModelCacheExpiry) return _cachedOllamaModel + const setting = await prisma.setting.findUnique({ where: { key: 'ollamaModel' } }) + const val = setting?.value ?? 'llama3.1' + _cachedOllamaModel = val + _ollamaModelCacheExpiry = Date.now() + CACHE_TTL + return val +} + +/** + * Get the Ollama base URL (cached for 5 minutes). + */ +export async function getOllamaBaseUrl(): Promise { + if (_cachedOllamaBaseUrl && Date.now() < _ollamaBaseUrlCacheExpiry) return _cachedOllamaBaseUrl + const setting = await prisma.setting.findUnique({ where: { key: 'ollamaBaseUrl' } }) + const val = setting?.value ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434' + _cachedOllamaBaseUrl = val + _ollamaBaseUrlCacheExpiry = Date.now() + CACHE_TTL + return val +} + /** * Get the model for the currently active provider. */ export async function getActiveModel(): Promise { const provider = await getProvider() - return provider === 'openai' ? getOpenAIModel() : getAnthropicModel() + if (provider === 'openai') return getOpenAIModel() + if (provider === 'ollama') return getOllamaModel() + return getAnthropicModel() } /** @@ -63,4 +98,8 @@ export function invalidateSettingsCache(): void { _providerCacheExpiry = 0 _cachedOpenAIModel = null _openAIModelCacheExpiry = 0 + _cachedOllamaModel = null + _ollamaModelCacheExpiry = 0 + _cachedOllamaBaseUrl = null + _ollamaBaseUrlCacheExpiry = 0 }