Skip to content

Commit 8940069

Browse files
committed
fix(provider): address Qdrant review findings
1 parent 64aed1c commit 8940069

4 files changed

Lines changed: 182 additions & 29 deletions

File tree

src/providers/qdrant/actions.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,33 @@ import { defineProviderAction } from "../../core/provider-definition.ts";
55

66
const service = "qdrant";
77

8-
const collectionNameSchema = s.nonEmptyString("The Qdrant collection name.");
9-
const pointIdSchema = s.union([s.nonNegativeInteger("A non-negative numeric point ID."), s.uuid("A UUID point ID.")]);
8+
const collectionNameSchema = {
9+
...s.nonEmptyString("The Qdrant collection name. The names `.` and `..` are not supported."),
10+
not: { enum: [".", ".."] },
11+
};
12+
export const qdrantUuidPattern =
13+
"^(?:[0-9A-Fa-f]{32}|[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}|[uU][rR][nN]:[uU][uU][iI][dD]:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})$";
14+
const pointIdSchema = s.union(
15+
[
16+
s.nonNegativeInteger("A numeric point ID within the JavaScript safe-integer range.", {
17+
maximum: Number.MAX_SAFE_INTEGER,
18+
}),
19+
s.stringPattern(qdrantUuidPattern, {
20+
description: "A Qdrant UUID point ID in simple, hyphenated, or URN form.",
21+
}),
22+
],
23+
{ description: "A Qdrant numeric or UUID point ID." },
24+
);
1025
const vectorSchema = s.array("A dense unnamed vector.", s.number("One vector component."), { minItems: 1 });
1126
const payloadSchema = s.looseObject("A JSON object stored with the point.");
1227
const filterSchema = s.looseObject("A Qdrant filter. Nested conditions are validated by Qdrant.", {
1328
must: s.unknown("Conditions that must match."),
1429
must_not: s.unknown("Conditions that must not match."),
1530
should: s.unknown("Conditions where at least one should match."),
16-
min_should: s.unknown("Minimum number of should conditions that must match."),
31+
min_should: s.requiredObject("Conditions where at least `min_count` entries must match.", {
32+
conditions: s.array("The Qdrant filter conditions to evaluate.", s.unknown("One Qdrant filter condition.")),
33+
min_count: s.nonNegativeInteger("The minimum number of conditions that must match."),
34+
}),
1735
});
1836

1937
const pointSchema = s.object(
@@ -134,7 +152,11 @@ export const qdrantActions: ProviderActionDefinition[] = [
134152
),
135153
outputSchema: s.actionOutput(
136154
{
137-
operationId: s.nullable(s.nonNegativeInteger("The Qdrant operation ID when returned.")),
155+
operationId: s.nullable(
156+
s.nonNegativeInteger("The Qdrant operation ID when returned, within the JavaScript safe-integer range.", {
157+
maximum: Number.MAX_SAFE_INTEGER,
158+
}),
159+
),
138160
status: s.stringEnum("The Qdrant write status.", ["acknowledged", "completed", "wait_timeout"]),
139161
},
140162
"The Qdrant upsert operation result.",

src/providers/qdrant/definition.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ export const provider: ProviderDefinition = {
2323
inputType: "text",
2424
required: true,
2525
secret: false,
26-
placeholder: "https://your-cluster.cloud.qdrant.io:6333",
26+
placeholder: "https://your-cluster.cloud.qdrant.io",
2727
description:
28-
"The HTTPS REST URL for a Qdrant Cloud cluster. Only official cloud.qdrant.io endpoints on port 6333 are supported.",
28+
"The HTTPS REST URL for a Qdrant Cloud cluster. Only official cloud.qdrant.io endpoints on port 443 or 6333 are supported.",
2929
},
3030
{
3131
key: "apiKey",

src/providers/qdrant/runtime.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { ProviderFetch } from "../provider-runtime.ts";
22

33
import { describe, expect, it, vi } from "vitest";
4+
import { validateActionInput } from "../../core/validation.ts";
5+
import { qdrantActions } from "./actions.ts";
46
import { createQdrantContext, qdrantActionHandlers, validateQdrantCredential } from "./runtime.ts";
57

68
const clusterUrl = "https://test-cluster.cloud.qdrant.io:6333";
@@ -86,6 +88,51 @@ describe("Qdrant runtime", () => {
8688
expect(requestOf(point.fetcher).url).toBe(`${clusterUrl}/collections/my%20collection/points/42`);
8789
});
8890

91+
it("keeps the public point ID schema aligned with Qdrant UUID forms and safe numeric IDs", async () => {
92+
const action = qdrantActions.find((candidate) => candidate.name === "get_point")!;
93+
const ids = [
94+
"936DA01F9ABD4d9d80C702AF85C822A8",
95+
"01890f3e-9c4a-7cc2-b3e4-8fef7d7c9b3a",
96+
"urn:uuid:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4",
97+
Number.MAX_SAFE_INTEGER,
98+
];
99+
100+
for (const id of ids) {
101+
expect(validateActionInput(action, { collectionName: "documents", id }).valid).toBe(true);
102+
const request = setup(jsonResponse({ id }));
103+
await expect(
104+
qdrantActionHandlers.get_point({ collectionName: "documents", id }, request.context),
105+
).resolves.toEqual({ point: { id } });
106+
expect(requestOf(request.fetcher).url).toBe(
107+
`${clusterUrl}/collections/documents/points/${encodeURIComponent(String(id))}`,
108+
);
109+
}
110+
111+
const unsafeId = Number.MAX_SAFE_INTEGER + 1;
112+
expect(validateActionInput(action, { collectionName: "documents", id: unsafeId }).valid).toBe(false);
113+
const invalid = setup();
114+
await expect(
115+
qdrantActionHandlers.get_point({ collectionName: "documents", id: unsafeId }, invalid.context),
116+
).rejects.toMatchObject({ status: 400 });
117+
expect(invalid.fetcher).not.toHaveBeenCalled();
118+
});
119+
120+
it("preserves collection name whitespace and rejects dot-segment names", async () => {
121+
const spaced = setup(jsonResponse({ status: "green" }));
122+
await qdrantActionHandlers.get_collection({ collectionName: " documents " }, spaced.context);
123+
expect(requestOf(spaced.fetcher).url).toBe(`${clusterUrl}/collections/%20documents%20`);
124+
125+
const action = qdrantActions.find((candidate) => candidate.name === "get_collection")!;
126+
for (const collectionName of [".", ".."]) {
127+
expect(validateActionInput(action, { collectionName }).valid).toBe(false);
128+
const invalid = setup();
129+
await expect(qdrantActionHandlers.get_collection({ collectionName }, invalid.context)).rejects.toMatchObject({
130+
status: 400,
131+
});
132+
expect(invalid.fetcher).not.toHaveBeenCalled();
133+
}
134+
});
135+
89136
it("normalizes a null operation ID and rejects excluded point fields", async () => {
90137
const upsert = setup(jsonResponse({ operation_id: null, status: "wait_timeout" }));
91138
await expect(
@@ -105,6 +152,35 @@ describe("Qdrant runtime", () => {
105152
expect(invalid.fetcher).not.toHaveBeenCalled();
106153
});
107154

155+
it("rejects point and operation IDs that cannot be represented without precision loss", async () => {
156+
const point = setup(jsonResponse({ id: Number.MAX_SAFE_INTEGER + 1 }));
157+
await expect(
158+
qdrantActionHandlers.get_point({ collectionName: "documents", id: 1 }, point.context),
159+
).rejects.toMatchObject({
160+
status: 502,
161+
message: "Qdrant returned an invalid point ID",
162+
});
163+
164+
const query = setup(jsonResponse({ points: [{ id: Number.MAX_SAFE_INTEGER + 1 }] }));
165+
await expect(
166+
qdrantActionHandlers.query_points({ collectionName: "documents", vector: [0.1] }, query.context),
167+
).rejects.toMatchObject({
168+
status: 502,
169+
message: "Qdrant returned an invalid query point ID",
170+
});
171+
172+
const operation = setup(jsonResponse({ operation_id: Number.MAX_SAFE_INTEGER + 1, status: "completed" }));
173+
await expect(
174+
qdrantActionHandlers.upsert_points(
175+
{ collectionName: "documents", points: [{ id: 1, vector: [0.1] }] },
176+
operation.context,
177+
),
178+
).rejects.toMatchObject({
179+
status: 502,
180+
message: "Qdrant returned an invalid operation_id",
181+
});
182+
});
183+
108184
it("maps query and scroll inputs and normalizes the next page offset", async () => {
109185
const query = setup(jsonResponse({ points: [{ id: 1, version: 2, score: 0.9 }] }));
110186
const queryResult = await qdrantActionHandlers.query_points(
@@ -148,6 +224,15 @@ describe("Qdrant runtime", () => {
148224
});
149225
});
150226

227+
it("maps invalid response pagination offsets to provider errors", async () => {
228+
const { context } = setup(jsonResponse({ points: [], next_page_offset: "not-a-point-id" }));
229+
230+
await expect(qdrantActionHandlers.scroll_points({ collectionName: "documents" }, context)).rejects.toMatchObject({
231+
status: 502,
232+
message: "Qdrant returned an invalid next_page_offset",
233+
});
234+
});
235+
151236
it("preserves execute-phase permission errors and maps validation permission errors", async () => {
152237
const execute = setup(errorResponse("forbidden", 403));
153238
await expect(
@@ -165,10 +250,18 @@ describe("Qdrant runtime", () => {
165250
});
166251

167252
it("rejects unsafe or non-Qdrant Cloud cluster URLs", () => {
253+
for (const validUrl of [
254+
"https://test-cluster.cloud.qdrant.io",
255+
"https://test-cluster.cloud.qdrant.io:443",
256+
clusterUrl,
257+
]) {
258+
expect(() => createQdrantContext({ clusterUrl: validUrl, apiKey }, vi.fn() as ProviderFetch)).not.toThrow();
259+
}
260+
168261
for (const invalidUrl of [
169262
"http://test-cluster.cloud.qdrant.io:6333",
170263
"https://test-cluster.example.com:6333",
171-
"https://test-cluster.cloud.qdrant.io:443",
264+
"https://test-cluster.cloud.qdrant.io:6334",
172265
"https://test-cluster.cloud.qdrant.io:6333/path",
173266
"https://test-cluster.cloud.qdrant.io:6333?secret=1",
174267
"https://user:pass@test-cluster.cloud.qdrant.io:6333",

src/providers/qdrant/runtime.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import type { CredentialValidationResult } from "../../core/types.ts";
22
import type { ProviderFetch, ProviderRuntimeHandler } from "../provider-runtime.ts";
33

4-
import { compactObject, optionalInteger, optionalRecord, optionalString, requiredString } from "../../core/cast.ts";
5-
import { assertPublicHttpUrl } from "../../core/request.ts";
4+
import {
5+
compactObject,
6+
optionalInteger,
7+
optionalRawString,
8+
optionalRecord,
9+
optionalString,
10+
requiredString,
11+
} from "../../core/cast.ts";
12+
import { assertPublicHttpUrl, encodePathSegment } from "../../core/request.ts";
613
import {
714
createProviderTimeout,
815
isAbortSignalError,
@@ -11,11 +18,11 @@ import {
1118
providerUserAgent,
1219
readProviderTextBody,
1320
} from "../provider-runtime.ts";
21+
import { qdrantUuidPattern } from "./actions.ts";
1422

1523
const service = "qdrant";
1624
const requestTimeoutMs = 30_000;
1725
const qdrantHostnameSuffix = ".cloud.qdrant.io";
18-
const qdrantPort = "6333";
1926

2027
type QdrantRequestPhase = "validate" | "execute";
2128
type QdrantHttpMethod = "GET" | "POST" | "PUT";
@@ -39,7 +46,7 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
3946
const collectionName = readCollectionName(input);
4047
const payload = await requestQdrantJson(
4148
context,
42-
`/collections/${encodeURIComponent(collectionName)}`,
49+
`/collections/${encodePathSegment(collectionName)}`,
4350
"GET",
4451
undefined,
4552
"execute",
@@ -56,7 +63,7 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
5663
}
5764
const payload = await requestQdrantJson(
5865
context,
59-
`/collections/${encodeURIComponent(collectionName)}`,
66+
`/collections/${encodePathSegment(collectionName)}`,
6067
"PUT",
6168
{ vectors: { size: vectorSize, distance } },
6269
"execute",
@@ -69,7 +76,7 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
6976
const points = readPoints(input.points);
7077
const payload = await requestQdrantJson(
7178
context,
72-
`/collections/${encodeURIComponent(collectionName)}/points?wait=true`,
79+
`/collections/${encodePathSegment(collectionName)}/points?wait=true`,
7380
"PUT",
7481
{ points },
7582
"execute",
@@ -86,12 +93,16 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
8693
const id = readPointId(input.id);
8794
const payload = await requestQdrantJson(
8895
context,
89-
`/collections/${encodeURIComponent(collectionName)}/points/${encodeURIComponent(String(id))}`,
96+
`/collections/${encodePathSegment(collectionName)}/points/${encodePathSegment(id)}`,
9097
"GET",
9198
undefined,
9299
"execute",
93100
);
94-
return { point: requireObjectResult(payload, "Qdrant point response") };
101+
const point = requireObjectResult(payload, "Qdrant point response");
102+
if (!isPointId(point.id)) {
103+
throw providerResponseError("Qdrant returned an invalid point ID");
104+
}
105+
return { point };
95106
},
96107

97108
async query_points(input, context) {
@@ -107,13 +118,13 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
107118
};
108119
const payload = await requestQdrantJson(
109120
context,
110-
`/collections/${encodeURIComponent(collectionName)}/points/query`,
121+
`/collections/${encodePathSegment(collectionName)}/points/query`,
111122
"POST",
112123
compactObject(body),
113124
"execute",
114125
);
115126
const result = requireObjectResult(payload, "Qdrant query response");
116-
return { points: Array.isArray(result.points) ? result.points : [] };
127+
return { points: readPointRecords(result.points, "query") };
117128
},
118129

119130
async scroll_points(input, context) {
@@ -128,15 +139,15 @@ export const qdrantActionHandlers: Record<string, ProviderRuntimeHandler<QdrantC
128139
};
129140
const payload = await requestQdrantJson(
130141
context,
131-
`/collections/${encodeURIComponent(collectionName)}/points/scroll`,
142+
`/collections/${encodePathSegment(collectionName)}/points/scroll`,
132143
"POST",
133144
compactObject(body),
134145
"execute",
135146
);
136147
const result = requireObjectResult(payload, "Qdrant scroll response");
137148
const nextOffset = readNullablePointId(result.next_page_offset);
138149
return {
139-
points: Array.isArray(result.points) ? result.points : [],
150+
points: readPointRecords(result.points, "scroll"),
140151
nextOffset,
141152
complete: nextOffset === null,
142153
};
@@ -241,8 +252,8 @@ function normalizeQdrantClusterUrl(value: string | undefined): URL {
241252
if (url.username || url.password) {
242253
throw providerInputError("clusterUrl must not include credentials");
243254
}
244-
if (url.port !== qdrantPort) {
245-
throw providerInputError("clusterUrl must use port 6333");
255+
if (url.port !== "" && url.port !== "6333") {
256+
throw providerInputError("clusterUrl must use port 443 or 6333");
246257
}
247258
if (url.pathname !== "/" || url.search || url.hash) {
248259
throw providerInputError("clusterUrl must not include a path, query, or fragment");
@@ -284,7 +295,14 @@ function createQdrantError(status: number, payload: unknown, phase: QdrantReques
284295
}
285296

286297
function readCollectionName(input: Record<string, unknown>): string {
287-
return requiredString(input.collectionName, "collectionName", providerInputError);
298+
const collectionName = optionalRawString(input.collectionName);
299+
if (collectionName === undefined || collectionName.length === 0) {
300+
throw providerInputError("collectionName is required");
301+
}
302+
if (collectionName === "." || collectionName === "..") {
303+
throw providerInputError("collectionName must not be . or ..");
304+
}
305+
return collectionName;
288306
}
289307

290308
function readPoints(value: unknown): Record<string, unknown>[] {
@@ -315,17 +333,35 @@ function readPoints(value: unknown): Record<string, unknown>[] {
315333
}
316334

317335
function readPointId(value: unknown): number | string {
318-
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
336+
if (isPointId(value)) {
319337
return value;
320338
}
321-
if (typeof value === "string" && uuidPattern.test(value)) {
339+
throw providerInputError("id must be a non-negative safe integer or UUID");
340+
}
341+
342+
function readNullablePointId(value: unknown): number | string | null {
343+
if (value === null || value === undefined) {
344+
return null;
345+
}
346+
if (isPointId(value)) {
322347
return value;
323348
}
324-
throw providerInputError("id must be a non-negative integer or UUID");
349+
throw providerResponseError("Qdrant returned an invalid next_page_offset");
325350
}
326351

327-
function readNullablePointId(value: unknown): number | string | null {
328-
return value === null || value === undefined ? null : readPointId(value);
352+
function isPointId(value: unknown): value is number | string {
353+
return (
354+
(typeof value === "number" && Number.isSafeInteger(value) && value >= 0) ||
355+
(typeof value === "string" && uuidPattern.test(value))
356+
);
357+
}
358+
359+
function readPointRecords(value: unknown, operation: string): unknown[] {
360+
const points = Array.isArray(value) ? value : [];
361+
if (points.some((point) => !isPointId(optionalRecord(point)?.id))) {
362+
throw providerResponseError(`Qdrant returned an invalid ${operation} point ID`);
363+
}
364+
return points;
329365
}
330366

331367
function readVector(value: unknown, fieldName: string): number[] {
@@ -370,7 +406,9 @@ function readOptionalNumber(value: unknown, fieldName: string): number | undefin
370406
function readNullableInteger(value: unknown, fieldName: string): number | null {
371407
if (value === null || value === undefined) return null;
372408
const integer = optionalInteger(value);
373-
if (integer === undefined || integer < 0) throw providerResponseError(`Qdrant returned an invalid ${fieldName}`);
409+
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) {
410+
throw providerResponseError(`Qdrant returned an invalid ${fieldName}`);
411+
}
374412
return integer;
375413
}
376414

@@ -394,7 +432,7 @@ function isDistance(value: string): value is "Cosine" | "Euclid" | "Dot" | "Manh
394432
return value === "Cosine" || value === "Euclid" || value === "Dot" || value === "Manhattan";
395433
}
396434

397-
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
435+
const uuidPattern = new RegExp(qdrantUuidPattern);
398436

399437
function providerInputError(message: string): ProviderRequestError {
400438
return new ProviderRequestError(400, message);

0 commit comments

Comments
 (0)