From fd19ef4fdcdda1cfebe328af4358d1f5831c45f2 Mon Sep 17 00:00:00 2001 From: Ludovic Claude Date: Thu, 19 Mar 2026 10:29:20 +0100 Subject: [PATCH] feature: New pi-ai provider, test ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added pi-ai multi-model provider support end-to-end (backend + UI), including Provider/Model/Base URL/Headers/Compat settings and env var overrides. Implemented a pi-ai “Test config (ping)” button plus improved test endpoint behavior and UI feedback (toasts + inline success/fail status). Enabled LM Studio / OpenAI-compatible Base URL usage without a real API key, including automatic dummy key injection to satisfy pi-ai’s API-key requirement. --- README.md | 91 +++++ app/api/analyze/images/route.ts | 2 +- app/api/categorize/route.ts | 4 +- app/api/search/ai/route.ts | 4 +- app/api/settings/cli-status/route.ts | 6 +- app/api/settings/route.ts | 110 +++++- app/api/settings/test/route.ts | 119 +++++- app/settings/page.tsx | 301 +++++++++++++- lib/ai-client.ts | 200 +++++++++- lib/categorizer.ts | 81 ++-- lib/settings.ts | 54 ++- lib/vision-analyzer.ts | 63 ++- package-lock.json | 570 ++++++++++++++++++++++++++- package.json | 1 + 14 files changed, 1516 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 14f36f9..3e4b057 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,97 @@ New accounts include $5 free credit — enough for thousands of bookmarks at Hai --- +## pi-ai (Multi-model provider) + +Siftly supports a third AI provider option: **`pi-ai`**. + +This uses [`@mariozechner/pi-ai`](https://github.com/badlogic/pi-mono/tree/main/packages/ai) to talk to many providers and models, including **OpenAI-compatible endpoints**. + +### Configure in the UI (recommended) + +1. Go to **Settings → AI Provider** +2. Select **pi-ai (Multi-model)** +3. Fill in: + +| Field | Meaning | +|------:|---------| +| `pi-ai API Key` | The provider API key (if required) | +| `Provider ID` | Provider identifier supported by pi-ai (e.g. `openai`, `openrouter`, `groq`, `anthropic`, `google`, …) | +| `Model ID` | Model identifier for that provider | +| `Base URL` (optional) | For OpenAI-compatible servers (Ollama, vLLM, LM Studio, LiteLLM, custom gateways) | +| `Headers JSON` (optional) | Extra headers added to requests (JSON object) | +| `Compat JSON` (optional) | Compatibility tweaks (JSON object) | + +Then click **Save pi-ai config** and use the **Test** button to verify connectivity. + +### Sample configuration (LM Studio local server on M4 pro 48GB) + +- Provider ID: `openai` +- Model ID: `zai-org/glm-4.6v-flash` +- Base URL: `http://127.0.0.1:1234/v1` + +### Environment variable overrides + +You can also override pi-ai settings via env vars (useful for Docker / deployments): + +```bash +PI_AI_API_KEY=... +PI_AI_BASE_URL=... +PI_AI_HEADERS='{"Authorization":"Bearer ..."}' +PI_AI_COMPAT='{}' + +# aliases also supported +PIAI_API_KEY=... +PIAI_BASE_URL=... +``` + +### Example presets + +#### OpenAI + +- Provider ID: `openai` +- Model ID: `gpt-4o-mini` +- Base URL: *(empty)* + +#### OpenRouter + +- Provider ID: `openrouter` +- Model ID: `openai/gpt-4o-mini` *(or any OpenRouter model slug)* +- Base URL: *(empty)* +- Headers JSON (optional): + +```json +{ "HTTP-Referer": "http://localhost:3000", "X-Title": "Siftly" } +``` + +#### Groq + +- Provider ID: `groq` +- Model ID: `llama-3.1-70b-versatile` +- Base URL: *(empty)* + +#### Local OpenAI-compatible (Ollama) + +This works with any server exposing an OpenAI-compatible API. + +- Provider ID: `openai` +- Model ID: `llama3.1` +- Base URL: `http://localhost:11434/v1` + +If your endpoint does not require a key, you can leave the API key blank. + +### Smoke test + +After configuring `pi-ai` in Settings: + +1. Click **Test** next to your pi-ai key. +2. Run the pipeline on a small import to confirm: + - Vision analysis + - Semantic tags + - Categorization + +--- + ## Importing Your Bookmarks Siftly has **built-in import tools** — no browser extensions required. Go to the **Import** page and choose either method: diff --git a/app/api/analyze/images/route.ts b/app/api/analyze/images/route.ts index 4366026..37a6f7d 100644 --- a/app/api/analyze/images/route.ts +++ b/app/api/analyze/images/route.ts @@ -24,7 +24,7 @@ export async function POST(request: NextRequest): Promise { } const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'pi-ai' ? 'piAiApiKey' : 'anthropicApiKey' const setting = await prisma.setting.findUnique({ where: { key: keyName } }) const dbKey = setting?.value?.trim() diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts index 71f6017..c776774 100644 --- a/app/api/categorize/route.ts +++ b/app/api/categorize/route.ts @@ -111,7 +111,7 @@ export async function POST(request: NextRequest): Promise { if (apiKey && typeof apiKey === 'string' && apiKey.trim() !== '') { const currentProvider = await getProvider() - const keySlot = currentProvider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' + const keySlot = currentProvider === 'openai' ? 'openaiApiKey' : currentProvider === 'pi-ai' ? 'piAiApiKey' : 'anthropicApiKey' await prisma.setting.upsert({ where: { key: keySlot }, update: { value: apiKey.trim() }, @@ -145,7 +145,7 @@ export async function POST(request: NextRequest): Promise { }) const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'pi-ai' ? 'piAiApiKey' : 'anthropicApiKey' const dbApiKey = (await prisma.setting.findUnique({ where: { key: keyName } }))?.value?.trim() || '' diff --git a/app/api/search/ai/route.ts b/app/api/search/ai/route.ts index 5d62886..22e4bb8 100644 --- a/app/api/search/ai/route.ts +++ b/app/api/search/ai/route.ts @@ -32,12 +32,12 @@ let _categoriesCacheExpiry = 0 async function getDbApiKey(): Promise { if (_apiKey !== null && Date.now() < _apiKeyExpiry) return _apiKey const provider = await getProvider() - const keyName = provider === 'openai' ? 'openaiApiKey' : 'anthropicApiKey' + const keyName = provider === 'openai' ? 'openaiApiKey' : provider === 'pi-ai' ? 'piAiApiKey' : 'anthropicApiKey' const setting = await prisma.setting.findUnique({ where: { key: keyName } }) const fromDb = setting?.value?.trim() ?? '' _apiKey = fromDb _apiKeyExpiry = Date.now() + 60_000 - return _apiKey + return fromDb } async function getAllCategories() { if (_categoriesCache && Date.now() < _categoriesCacheExpiry) return _categoriesCache diff --git a/app/api/settings/cli-status/route.ts b/app/api/settings/cli-status/route.ts index a84d1e9..2bf3835 100644 --- a/app/api/settings/cli-status/route.ts +++ b/app/api/settings/cli-status/route.ts @@ -10,7 +10,11 @@ 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 provider = providerSetting?.value === 'openai' + ? 'openai' + : providerSetting?.value === 'pi-ai' + ? 'pi-ai' + : 'anthropic' // Only check CLI subprocess availability if OAuth credentials exist const cliDirectAvailable = oauthStatus.available && !oauthStatus.expired diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index f06373e..e88a69f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -24,12 +24,18 @@ 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, piAiKey, piAiProvider, piAiModel, piAiBaseUrl, piAiHeaders, piAiCompat, 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: 'piAiApiKey' } }), + prisma.setting.findUnique({ where: { key: 'piAiProvider' } }), + prisma.setting.findUnique({ where: { key: 'piAiModel' } }), + prisma.setting.findUnique({ where: { key: 'piAiBaseUrl' } }), + prisma.setting.findUnique({ where: { key: 'piAiHeaders' } }), + prisma.setting.findUnique({ where: { key: 'piAiCompat' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), ]) @@ -42,6 +48,13 @@ export async function GET(): Promise { openaiApiKey: maskKey(openai?.value ?? null), hasOpenaiKey: openai !== null, openaiModel: openaiModel?.value ?? 'gpt-4.1-mini', + piAiApiKey: maskKey(piAiKey?.value ?? null), + hasPiAiKey: piAiKey !== null, + piAiProvider: piAiProvider?.value ?? 'openai', + piAiModel: piAiModel?.value ?? 'gpt-4o-mini', + piAiBaseUrl: piAiBaseUrl?.value ?? null, + piAiHeaders: piAiHeaders?.value ?? null, + piAiCompat: piAiCompat?.value ?? null, xOAuthClientId: maskKey(xClientId?.value ?? null), xOAuthClientSecret: maskKey(xClientSecret?.value ?? null), hasXOAuth: !!xClientId?.value, @@ -62,6 +75,12 @@ export async function POST(request: NextRequest): Promise { provider?: string openaiApiKey?: string openaiModel?: string + piAiApiKey?: string + piAiProvider?: string + piAiModel?: string + piAiBaseUrl?: string + piAiHeaders?: string + piAiCompat?: string xOAuthClientId?: string xOAuthClientSecret?: string } = {} @@ -75,7 +94,7 @@ export async function POST(request: NextRequest): Promise { // Save provider if provided if (provider !== undefined) { - if (provider !== 'anthropic' && provider !== 'openai') { + if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'pi-ai') { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }) } await prisma.setting.upsert({ @@ -87,6 +106,91 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ saved: true }) } + // Save pi-ai fields if provided + const { piAiApiKey, piAiProvider, piAiModel, piAiBaseUrl, piAiHeaders, piAiCompat } = body + + const hasAnyPiAiField = + piAiApiKey !== undefined || + piAiProvider !== undefined || + piAiModel !== undefined || + piAiBaseUrl !== undefined || + piAiHeaders !== undefined || + piAiCompat !== undefined + + if (hasAnyPiAiField) { + if (piAiApiKey !== undefined) { + if (typeof piAiApiKey !== 'string' || piAiApiKey.trim() === '') { + return NextResponse.json({ error: 'Invalid piAiApiKey value' }, { status: 400 }) + } + } + if (piAiProvider !== undefined) { + if (typeof piAiProvider !== 'string' || piAiProvider.trim() === '') { + return NextResponse.json({ error: 'Invalid piAiProvider value' }, { status: 400 }) + } + } + if (piAiModel !== undefined) { + if (typeof piAiModel !== 'string' || piAiModel.trim() === '') { + return NextResponse.json({ error: 'Invalid piAiModel value' }, { status: 400 }) + } + } + if (piAiBaseUrl !== undefined) { + if (typeof piAiBaseUrl !== 'string') { + return NextResponse.json({ error: 'Invalid piAiBaseUrl value' }, { status: 400 }) + } + } + if (piAiHeaders !== undefined) { + if (typeof piAiHeaders !== 'string') { + return NextResponse.json({ error: 'Invalid piAiHeaders value' }, { status: 400 }) + } + const trimmed = piAiHeaders.trim() + if (trimmed) { + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return NextResponse.json({ error: 'piAiHeaders must be a JSON object' }, { status: 400 }) + } + } catch { + return NextResponse.json({ error: 'piAiHeaders must be valid JSON' }, { status: 400 }) + } + } + } + if (piAiCompat !== undefined) { + if (typeof piAiCompat !== 'string') { + return NextResponse.json({ error: 'Invalid piAiCompat value' }, { status: 400 }) + } + const trimmed = piAiCompat.trim() + if (trimmed) { + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return NextResponse.json({ error: 'piAiCompat must be a JSON object' }, { status: 400 }) + } + } catch { + return NextResponse.json({ error: 'piAiCompat must be valid JSON' }, { status: 400 }) + } + } + } + + const toUpsert: { key: string; value: string }[] = [] + if (piAiApiKey !== undefined) toUpsert.push({ key: 'piAiApiKey', value: piAiApiKey.trim() }) + if (piAiProvider !== undefined) toUpsert.push({ key: 'piAiProvider', value: piAiProvider.trim() }) + if (piAiModel !== undefined) toUpsert.push({ key: 'piAiModel', value: piAiModel.trim() }) + if (piAiBaseUrl !== undefined) toUpsert.push({ key: 'piAiBaseUrl', value: piAiBaseUrl.trim() }) + if (piAiHeaders !== undefined) toUpsert.push({ key: 'piAiHeaders', value: piAiHeaders.trim() }) + if (piAiCompat !== undefined) toUpsert.push({ key: 'piAiCompat', value: piAiCompat.trim() }) + + for (const { key, value } of toUpsert) { + await prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }) + } + + invalidateSettingsCache() + return NextResponse.json({ saved: true }) + } + // Save Anthropic model if provided if (anthropicModel !== undefined) { if (!(ALLOWED_ANTHROPIC_MODELS as readonly string[]).includes(anthropicModel)) { @@ -198,7 +302,7 @@ export async function DELETE(request: NextRequest): Promise { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const allowed = ['anthropicApiKey', 'openaiApiKey', 'x_oauth_client_id', 'x_oauth_client_secret'] + const allowed = ['anthropicApiKey', 'openaiApiKey', 'piAiApiKey', 'piAiProvider', 'piAiModel', 'piAiBaseUrl', 'piAiHeaders', 'piAiCompat', 'x_oauth_client_id', 'x_oauth_client_secret'] if (!body.key || !allowed.includes(body.key)) { return NextResponse.json({ error: 'Invalid key' }, { status: 400 }) } diff --git a/app/api/settings/test/route.ts b/app/api/settings/test/route.ts index 84c12af..015c1fb 100644 --- a/app/api/settings/test/route.ts +++ b/app/api/settings/test/route.ts @@ -2,9 +2,19 @@ import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/db' import { resolveAnthropicClient, getCliAuthStatus } from '@/lib/claude-cli-auth' import { resolveOpenAIClient } from '@/lib/openai-auth' +import { complete, getModel, type Model } from '@mariozechner/pi-ai' export async function POST(request: NextRequest): Promise { - let body: { provider?: string } = {} + let body: { + provider?: string + message?: string + piAiApiKey?: string + piAiProvider?: string + piAiModel?: string + piAiBaseUrl?: string + piAiHeaders?: string + piAiCompat?: string + } = {} try { const text = await request.text() if (text.trim()) body = JSON.parse(text) @@ -13,6 +23,113 @@ export async function POST(request: NextRequest): Promise { } const provider = body.provider ?? 'anthropic' + const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : 'hi' + + if (provider === 'pi-ai') { + const [piAiKey, piAiProvider, piAiModel, piAiBaseUrl, piAiHeaders, piAiCompat] = await Promise.all([ + prisma.setting.findUnique({ where: { key: 'piAiApiKey' } }), + prisma.setting.findUnique({ where: { key: 'piAiProvider' } }), + prisma.setting.findUnique({ where: { key: 'piAiModel' } }), + prisma.setting.findUnique({ where: { key: 'piAiBaseUrl' } }), + prisma.setting.findUnique({ where: { key: 'piAiHeaders' } }), + prisma.setting.findUnique({ where: { key: 'piAiCompat' } }), + ]) + + const providerId = ( + (typeof body.piAiProvider === 'string' && body.piAiProvider.trim() ? body.piAiProvider : piAiProvider?.value ?? 'openai') + ).trim() + const modelId = ( + (typeof body.piAiModel === 'string' && body.piAiModel.trim() ? body.piAiModel : piAiModel?.value ?? 'gpt-4o-mini') + ).trim() + const baseUrlRaw = + typeof body.piAiBaseUrl === 'string' && body.piAiBaseUrl.trim() + ? body.piAiBaseUrl + : (piAiBaseUrl?.value ?? '') + const baseUrl = baseUrlRaw.trim() || null + + const safeJsonParseObject = (raw: string | null | undefined): Record | null => { + if (!raw?.trim()) return null + try { + const parsed = JSON.parse(raw) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed as Record + return null + } catch { + return null + } + } + + const headers = safeJsonParseObject( + typeof body.piAiHeaders === 'string' ? body.piAiHeaders : piAiHeaders?.value ?? null, + ) as Record | null + const compat = safeJsonParseObject( + typeof body.piAiCompat === 'string' ? body.piAiCompat : piAiCompat?.value ?? null, + ) + + let model: Model + if (!baseUrl && !headers && !compat) { + model = getModel(providerId as any, modelId as any) as any + } else { + model = { + id: modelId, + name: modelId, + api: 'openai-completions', + provider: 'openai', + baseUrl: baseUrl || undefined, + reasoning: false, + input: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + ...(headers ? { headers } : {}), + ...(compat ? { compat: compat as any } : {}), + } as any + } + + const apiKeyRaw = + typeof body.piAiApiKey === 'string' && body.piAiApiKey.trim() + ? body.piAiApiKey + : (piAiKey?.value ?? '') + const apiKey = apiKeyRaw.trim() || null + if (!apiKey && !baseUrl) { + return NextResponse.json( + { working: false, error: 'No pi-ai API key found and no Base URL configured.' }, + { status: 400 }, + ) + } + + const effectiveApiKey = apiKey || (baseUrl ? 'local' : null) + + try { + const response = await complete( + model, + { + messages: [{ role: 'user', content: [{ type: 'text', text: message }] }], + } as any, + { + apiKey: effectiveApiKey || undefined, + maxTokens: 5, + } as any, + ) + + const hasAnyText = Array.isArray((response as any).content) + ? (response as any).content.some((b: any) => b?.type === 'text' && typeof b.text === 'string') + : false + + if (!hasAnyText) { + return NextResponse.json({ working: false, error: 'No text content returned from model.' }) + } + + return NextResponse.json({ working: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const friendly = msg.includes('401') || msg.includes('invalid_api_key') + ? 'Invalid API key' + : msg.includes('403') + ? 'Key does not have permission' + : msg.slice(0, 120) + return NextResponse.json({ working: false, error: friendly }) + } + } if (provider === 'anthropic') { const setting = await prisma.setting.findUnique({ where: { key: 'anthropicApiKey' } }) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f022624..70bd4c2 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, type ChangeEvent } from 'react' import { Eye, EyeOff, @@ -106,7 +106,7 @@ function ApiKeyField({ }: { label: string placeholder: string - fieldKey: 'anthropicApiKey' | 'openaiApiKey' + fieldKey: 'anthropicApiKey' | 'openaiApiKey' | 'piAiApiKey' hint: string docHref: string onToast: (t: Toast) => void @@ -125,7 +125,11 @@ function ApiKeyField({ fetch('/api/settings') .then((r) => r.json()) .then((d: Record) => { - const hasKeyField = fieldKey === 'openaiApiKey' ? 'hasOpenaiKey' : 'hasAnthropicKey' + const hasKeyField = fieldKey === 'openaiApiKey' + ? 'hasOpenaiKey' + : fieldKey === 'piAiApiKey' + ? 'hasPiAiKey' + : 'hasAnthropicKey' const hasKey = d[hasKeyField] const masked = d[fieldKey] as string | null if (hasKey && masked) setSavedMasked(masked) @@ -344,7 +348,7 @@ function ModelSelector({
) => { + const presetId = e.target.value + setPiAiPreset(presetId) + if (presetId) applyPiAiPreset(presetId) + }} + className="w-full appearance-none pl-3 pr-8 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm focus:outline-none focus:border-indigo-500 transition-colors cursor-pointer" + > + + + + + + + +
+

Presets fill the fields below. You can edit anything after applying.

+ + +
+
+

Provider ID

+ ) => setPiAiProvider(e.target.value)} + placeholder="openai | anthropic | google | groq | openrouter | ..." + 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-indigo-500 transition-all duration-200" + /> +
+
+

Model ID

+ ) => setPiAiModel(e.target.value)} + placeholder="gpt-4o-mini" + 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-indigo-500 transition-all duration-200" + /> +
+
+ +
+

Base URL (optional)

+ ) => setPiAiBaseUrl(e.target.value)} + placeholder="http://localhost:11434/v1" + 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-indigo-500 transition-all duration-200" + /> +

Set this for any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, LiteLLM, etc.).

+
+ +
+

Headers JSON (optional)

+