-
Notifications
You must be signed in to change notification settings - Fork 3.5k
feat(knowledge): add Ollama embedding provider support #3714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
255640f
b043bc2
61f05a7
546dd7c
616761d
133f326
2693251
18e7ac2
983efc3
53a1423
0b5d218
606b70b
da36fcd
b9e6ab7
b1e92b8
3698a04
988158e
166a7f3
2f30934
f88e9f9
863e497
546061e
00b3c7d
075b005
ea59193
ee3cc30
e6d0a60
0812f3b
fd8d2b3
5c872c4
4571299
322dc4e
ef84871
d308fe0
aa452f4
8445d7e
185007a
456eaa4
1570b02
0e1dcf7
e2b8189
ea3dd08
24779a7
547de40
7afb708
2cdb519
507cc36
50858d4
5bdfe15
f6d121e
d210669
ff08fb0
5cebdea
2552edc
c6fde92
71b1769
61d7936
1991604
dbabedd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,9 +5,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' | |
| import { getSession } from '@/lib/auth' | ||
| import { PlatformEvents } from '@/lib/core/telemetry' | ||
| import { generateRequestId } from '@/lib/core/utils/request' | ||
| import { | ||
| createKBEmbeddingTable, | ||
| dropKBEmbeddingTable, | ||
| parseEmbeddingModel, | ||
| } from '@/lib/knowledge/dynamic-tables' | ||
| import { getOllamaBaseUrl, validateOllamaModel } from '@/lib/knowledge/embeddings' | ||
| import { | ||
| createKnowledgeBase, | ||
| getKnowledgeBases, | ||
| hardDeleteKnowledgeBase, | ||
| KnowledgeBaseConflictError, | ||
| type KnowledgeBaseScope, | ||
| } from '@/lib/knowledge/service' | ||
|
|
@@ -26,8 +33,71 @@ const CreateKnowledgeBaseSchema = z.object({ | |
| name: z.string().min(1, 'Name is required'), | ||
| description: z.string().optional(), | ||
| workspaceId: z.string().min(1, 'Workspace ID is required'), | ||
| embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), | ||
| embeddingDimension: z.literal(1536).default(1536), | ||
| embeddingModel: z | ||
| .union([ | ||
| z.literal('text-embedding-3-small'), | ||
| z.literal('text-embedding-3-large'), | ||
| z.string().regex(/^ollama\/.+/, 'Ollama models must be prefixed with "ollama/"'), | ||
| ]) | ||
| .default('text-embedding-3-small'), | ||
| embeddingDimension: z.number().int().min(64).max(8192).default(1536), | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ollamaBaseUrl: z | ||
| .string() | ||
| .url('Ollama base URL must be a valid URL') | ||
| .refine( | ||
| (url) => { | ||
| try { | ||
| const parsed = new URL(url) | ||
| // Only allow http/https schemes | ||
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | ||
| return false | ||
| } | ||
| const hostname = parsed.hostname.toLowerCase() | ||
| // Block known cloud metadata endpoints | ||
| if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') { | ||
| return false | ||
| } | ||
| // Block IPv6 addresses (except loopback) — prevents IPv6-mapped IPv4 bypass | ||
| // URL.hostname keeps brackets for IPv6, e.g. "[::ffff:169.254.169.254]" | ||
| if (hostname.startsWith('[') && hostname !== '[::1]') { | ||
| return false | ||
| } | ||
| // Allow localhost and IPv6 loopback | ||
| if (hostname === 'localhost' || hostname === '[::1]') { | ||
| return true | ||
| } | ||
| // Allow private IPv4 ranges — only match actual IPs, not domains like "10.evil.com" | ||
| const ipv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ | ||
| if (ipv4.test(hostname)) { | ||
| if ( | ||
| hostname.startsWith('127.') || | ||
| hostname.startsWith('10.') || | ||
| hostname.startsWith('192.168.') | ||
| ) { | ||
| return true | ||
| } | ||
| if (hostname.startsWith('172.')) { | ||
| const second = Number.parseInt(hostname.split('.')[1], 10) | ||
| if (second >= 16 && second <= 31) return true | ||
| } | ||
| return false | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Allow Docker service hostnames (no dots = not a public domain) | ||
| // e.g. "ollama", "host.docker.internal" | ||
| if (!hostname.includes('.') || hostname.endsWith('.internal')) { | ||
| return true | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return false | ||
| } catch { | ||
| return false | ||
| } | ||
| }, | ||
| { | ||
| message: | ||
| 'Ollama base URL must point to localhost, a private network address, or a Docker service hostname', | ||
| } | ||
| ) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .optional(), | ||
| chunkingConfig: z | ||
| .object({ | ||
| /** Maximum chunk size in tokens (1 token ≈ 4 characters) */ | ||
|
|
@@ -98,13 +168,80 @@ export async function POST(req: NextRequest) { | |
| try { | ||
| const validatedData = CreateKnowledgeBaseSchema.parse(body) | ||
|
|
||
| const { provider, modelName } = parseEmbeddingModel(validatedData.embeddingModel) | ||
|
|
||
| // For Ollama models, validate the model is available and auto-detect dimension | ||
| let effectiveDimension = validatedData.embeddingDimension | ||
| if (provider === 'ollama') { | ||
| const ollamaBaseUrl = getOllamaBaseUrl(validatedData.ollamaBaseUrl) | ||
| try { | ||
| const modelInfo = await validateOllamaModel(modelName, ollamaBaseUrl) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Auto-correct dimension if the model reports a different one | ||
| if (modelInfo.embeddingLength && modelInfo.embeddingLength !== effectiveDimension) { | ||
| if (modelInfo.embeddingLength < 64 || modelInfo.embeddingLength > 8192) { | ||
| return NextResponse.json( | ||
| { | ||
| error: `Ollama model "${modelName}" reported an unsupported embedding dimension (${modelInfo.embeddingLength}). Supported range: 64–8192.`, | ||
| }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
| logger.info( | ||
| `[${requestId}] Auto-correcting embedding dimension from ${effectiveDimension} ` + | ||
| `to ${modelInfo.embeddingLength} (reported by Ollama model ${modelName})` | ||
| ) | ||
| effectiveDimension = modelInfo.embeddingLength | ||
| } | ||
| } catch { | ||
| return NextResponse.json( | ||
| { | ||
| error: | ||
| `Cannot reach Ollama at ${ollamaBaseUrl} or model "${modelName}" is not available. ` + | ||
| `Make sure Ollama is running and the model is pulled (ollama pull ${modelName}).`, | ||
| }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| const createData = { | ||
| ...validatedData, | ||
| embeddingDimension: effectiveDimension, | ||
| userId: session.user.id, | ||
| } | ||
|
|
||
| const newKnowledgeBase = await createKnowledgeBase(createData, requestId) | ||
|
|
||
| if (provider === 'ollama') { | ||
| try { | ||
| await createKBEmbeddingTable(newKnowledgeBase.id, effectiveDimension) | ||
| } catch (tableError) { | ||
| logger.error( | ||
| `[${requestId}] Failed to create embedding table for KB ${newKnowledgeBase.id}`, | ||
| tableError | ||
| ) | ||
| // Hard-delete the KB row — this is a creation-time rollback, not a user-initiated | ||
| // deletion, so a soft delete would leave a restorable broken KB in the archive. | ||
| try { | ||
| await dropKBEmbeddingTable(newKnowledgeBase.id) | ||
| await hardDeleteKnowledgeBase(newKnowledgeBase.id) | ||
| logger.info( | ||
| `[${requestId}] Cleaned up orphaned KB ${newKnowledgeBase.id} after table creation failure` | ||
| ) | ||
| } catch (cleanupError) { | ||
| logger.error( | ||
| `[${requestId}] Failed to clean up orphaned KB ${newKnowledgeBase.id}`, | ||
| cleanupError | ||
| ) | ||
| } | ||
| return NextResponse.json( | ||
| { error: 'Failed to create embedding storage. Please try again.' }, | ||
| { status: 500 } | ||
| ) | ||
| } | ||
|
Comment on lines
214
to
+242
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A safe fix is to delete the orphaned KB row in the } catch (tableError) {
logger.error(...)
// Clean up orphaned KB row
await deleteKnowledgeBase(newKnowledgeBase.id, requestId).catch(() => {})
throw tableError
} |
||
| } | ||
|
|
||
| try { | ||
| PlatformEvents.knowledgeBaseCreated({ | ||
| knowledgeBaseId: newKnowledgeBase.id, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.