Skip to content
Open
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
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion app/api/analyze/images/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}

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()

Expand Down
4 changes: 2 additions & 2 deletions app/api/categorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {

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() },
Expand Down Expand Up @@ -145,7 +145,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
})

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() || ''

Expand Down
4 changes: 2 additions & 2 deletions app/api/search/ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ let _categoriesCacheExpiry = 0
async function getDbApiKey(): Promise<string> {
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
Expand Down
6 changes: 5 additions & 1 deletion app/api/settings/cli-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ export async function GET(): Promise<NextResponse> {
// 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
Expand Down
110 changes: 107 additions & 3 deletions app/api/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ const ALLOWED_OPENAI_MODELS = [

export async function GET(): Promise<NextResponse> {
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' } }),
])
Expand All @@ -42,6 +48,13 @@ export async function GET(): Promise<NextResponse> {
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,
Expand All @@ -62,6 +75,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
provider?: string
openaiApiKey?: string
openaiModel?: string
piAiApiKey?: string
piAiProvider?: string
piAiModel?: string
piAiBaseUrl?: string
piAiHeaders?: string
piAiCompat?: string
xOAuthClientId?: string
xOAuthClientSecret?: string
} = {}
Expand All @@ -75,7 +94,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {

// 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({
Expand All @@ -87,6 +106,91 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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)) {
Expand Down Expand Up @@ -198,7 +302,7 @@ export async function DELETE(request: NextRequest): Promise<NextResponse> {
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 })
}
Expand Down
Loading