fix(qdrant): resolve URL port handling bug for HTTPS URLs (#4992)

Co-authored-by: Ben Ashby <bashby@ximasoftware.com>
This commit is contained in:
Ben Ashby 2025-06-22 20:49:59 -06:00 committed by GitHub
parent 8eb147346d
commit f39da324d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 337 additions and 11 deletions

View file

@ -58,7 +58,9 @@ describe("QdrantVectorStore", () => {
it("should correctly initialize QdrantClient and collectionName in constructor", () => {
expect(QdrantClient).toHaveBeenCalledTimes(1)
expect(QdrantClient).toHaveBeenCalledWith({
url: mockQdrantUrl,
host: "mock-qdrant",
https: false,
port: 6333,
apiKey: mockApiKey,
headers: {
"User-Agent": "Roo-Code",
@ -75,7 +77,9 @@ describe("QdrantVectorStore", () => {
const vectorStoreWithDefaults = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
url: "http://localhost:6333", // Should use default QDRANT_URL
host: "localhost",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
@ -87,7 +91,9 @@ describe("QdrantVectorStore", () => {
const vectorStoreWithoutKey = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
url: mockQdrantUrl,
host: "mock-qdrant",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
@ -95,6 +101,242 @@ describe("QdrantVectorStore", () => {
})
})
describe("URL Parsing and Explicit Port Handling", () => {
describe("HTTPS URL handling", () => {
it("should use explicit port 443 for HTTPS URLs without port (fixes the main bug)", () => {
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
"https://qdrant.ashbyfam.com",
mockVectorSize,
)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "qdrant.ashbyfam.com",
https: true,
port: 443,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com")
})
it("should use explicit port for HTTPS URLs with explicit port", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://example.com:9000", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "example.com",
https: true,
port: 9000,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("https://example.com:9000")
})
it("should use port 443 for HTTPS URLs with paths and query parameters", () => {
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
"https://example.com/api/v1?key=value",
mockVectorSize,
)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "example.com",
https: true,
port: 443,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("https://example.com/api/v1?key=value")
})
})
describe("HTTP URL handling", () => {
it("should use explicit port 80 for HTTP URLs without port", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://example.com", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "example.com",
https: false,
port: 80,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://example.com")
})
it("should use explicit port for HTTP URLs with explicit port", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:8080", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 8080,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:8080")
})
it("should use port 80 for HTTP URLs while preserving paths and query parameters", () => {
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
"http://example.com/api/v1?key=value",
mockVectorSize,
)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "example.com",
https: false,
port: 80,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://example.com/api/v1?key=value")
})
})
describe("Hostname handling", () => {
it("should convert hostname to http with port 80", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "qdrant.example.com", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "qdrant.example.com",
https: false,
port: 80,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://qdrant.example.com")
})
it("should handle hostname:port format with explicit port", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "localhost:6333", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
})
it("should handle explicit HTTP URLs correctly", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:9000", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 9000,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:9000")
})
})
describe("IP address handling", () => {
it("should convert IP address to http with port 80", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "192.168.1.100",
https: false,
port: 80,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100")
})
it("should handle IP:port format with explicit port", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100:6333", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "192.168.1.100",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100:6333")
})
})
describe("Edge cases", () => {
it("should handle undefined URL with host-based config", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
})
it("should handle empty string URL with host-based config", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
})
it("should handle whitespace-only URL with host-based config", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "localhost",
https: false,
port: 6333,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333")
})
})
describe("Invalid URL fallback", () => {
it("should treat invalid URLs as hostnames with port 80", () => {
const vectorStore = new QdrantVectorStore(mockWorkspacePath, "invalid-url-format", mockVectorSize)
expect(QdrantClient).toHaveBeenLastCalledWith({
host: "invalid-url-format",
https: false,
port: 80,
apiKey: undefined,
headers: {
"User-Agent": "Roo-Code",
},
})
expect((vectorStore as any).qdrantUrl).toBe("http://invalid-url-format")
})
})
})
describe("initialize", () => {
it("should create a new collection if none exists and return true", async () => {
// Mock getCollection to throw a 404-like error

View file

@ -24,14 +24,54 @@ export class QdrantVectorStore implements IVectorStore {
* @param url Optional URL to the Qdrant server
*/
constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) {
this.qdrantUrl = url || "http://localhost:6333"
this.client = new QdrantClient({
url: this.qdrantUrl,
apiKey,
headers: {
"User-Agent": "Roo-Code",
},
})
// Parse the URL to determine the appropriate QdrantClient configuration
const parsedUrl = this.parseQdrantUrl(url)
// Store the resolved URL for our property
this.qdrantUrl = parsedUrl
try {
const urlObj = new URL(parsedUrl)
// Always use host-based configuration with explicit ports to avoid QdrantClient defaults
let port: number
let useHttps: boolean
if (urlObj.port) {
// Explicit port specified - use it and determine protocol
port = Number(urlObj.port)
useHttps = urlObj.protocol === "https:"
} else {
// No explicit port - use protocol defaults
if (urlObj.protocol === "https:") {
port = 443
useHttps = true
} else {
// http: or other protocols default to port 80
port = 80
useHttps = false
}
}
this.client = new QdrantClient({
host: urlObj.hostname,
https: useHttps,
port: port,
apiKey,
headers: {
"User-Agent": "Roo-Code",
},
})
} catch (urlError) {
// If URL parsing fails, fall back to URL-based config
this.client = new QdrantClient({
url: parsedUrl,
apiKey,
headers: {
"User-Agent": "Roo-Code",
},
})
}
// Generate collection name from workspace path
const hash = createHash("sha256").update(workspacePath).digest("hex")
@ -39,6 +79,50 @@ export class QdrantVectorStore implements IVectorStore {
this.collectionName = `ws-${hash.substring(0, 16)}`
}
/**
* Parses and normalizes Qdrant server URLs to handle various input formats
* @param url Raw URL input from user
* @returns Properly formatted URL for QdrantClient
*/
private parseQdrantUrl(url: string | undefined): string {
// Handle undefined/null/empty cases
if (!url || url.trim() === "") {
return "http://localhost:6333"
}
const trimmedUrl = url.trim()
// Check if it starts with a protocol
if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.includes("://")) {
// No protocol - treat as hostname
return this.parseHostname(trimmedUrl)
}
try {
// Attempt to parse as complete URL - return as-is, let constructor handle ports
const parsedUrl = new URL(trimmedUrl)
return trimmedUrl
} catch {
// Failed to parse as URL - treat as hostname
return this.parseHostname(trimmedUrl)
}
}
/**
* Handles hostname-only inputs
* @param hostname Raw hostname input
* @returns Properly formatted URL with http:// prefix
*/
private parseHostname(hostname: string): string {
if (hostname.includes(":")) {
// Has port - add http:// prefix if missing
return hostname.startsWith("http") ? hostname : `http://${hostname}`
} else {
// No port - add http:// prefix without port (let constructor handle port assignment)
return `http://${hostname}`
}
}
private async getCollectionInfo(): Promise<Schemas["CollectionInfo"] | null> {
try {
const collectionInfo = await this.client.getCollection(this.collectionName)