Skip to content

Commit d07e27b

Browse files
vinkiYul1shen
andauthored
feat(provider): add Qdrant Cloud REST provider (#218)
## 中文 ### 概要 新增 Qdrant Cloud Provider,通过官方 REST API 提供 7 个可本地执行的向量数据库 Action: - `qdrant.list_collections` - `qdrant.get_collection` - `qdrant.create_collection` - `qdrant.upsert_points` - `qdrant.get_point` - `qdrant.query_points` - `qdrant.scroll_points` ### 主要变更 - 使用 `custom_credential`,支持 Qdrant Cloud `clusterUrl` 和 `apiKey`。 - Credential Validator 通过 `GET /collections` 验证数据库访问权限。 - 仅支持 HTTPS、官方 `*.cloud.qdrant.io` 主机和 `6333` 端口。 - 所有请求使用共享 SSRF 防护 Fetch,不启用私网访问或 DNS 校验绕过。 - 支持 unnamed dense vectors、JSON payload、基础 Qdrant filter 和单页 scroll 分页。 - Upsert 使用单次 `PUT .../points?wait=true` 请求,避免写入与可见性之间的竞态。 - 统一处理 Qdrant 响应包络、超时、取消、非 JSON 响应和 HTTP 错误。 - 严格拒绝 named/sparse vector 字段,保持首版能力边界清晰。 - 未增加 npm 依赖、共享运行时改动或 Provider Proxy。 ### 验证 - Qdrant 定向测试:9/9 通过。 - 完整测试:58 个文件、557 个测试全部通过。 - TypeScript 检查通过。 - oxlint 通过。 - oxfmt 格式检查通过。 - Catalog 已生成并包含 7 个 Qdrant Action;生成文件未提交。 ## English ### Summary Add a locally executable Qdrant Cloud Provider backed by the official REST API with seven vector-database actions: - `qdrant.list_collections` - `qdrant.get_collection` - `qdrant.create_collection` - `qdrant.upsert_points` - `qdrant.get_point` - `qdrant.query_points` - `qdrant.scroll_points` ### Changes - Add `custom_credential` authentication with Qdrant Cloud `clusterUrl` and `apiKey`. - Validate credentials through `GET /collections` to verify database access. - Restrict URLs to HTTPS, official `*.cloud.qdrant.io` hosts, and port `6333`. - Route every request through the shared SSRF-protected Fetch without private-network access or DNS-validation bypasses. - Support unnamed dense vectors, JSON payloads, basic Qdrant filters, and single-page scroll pagination. - Use one `PUT .../points?wait=true` request for upserts to provide write visibility without a separate race-prone request. - Normalize Qdrant response envelopes and handle timeouts, cancellation, non-JSON responses, and HTTP errors consistently. - Reject named/sparse vector fields explicitly to keep the initial capability boundary clear. - Add no npm dependencies, shared runtime changes, or Provider Proxy. ### Verification - Qdrant focused tests: 9/9 passed. - Full test suite: 58 files, 557 tests passed. - TypeScript check passed. - oxlint passed. - oxfmt check passed. - Catalog generation includes all seven Qdrant actions; generated files are not committed. ## Scope Notes This initial provider intentionally excludes self-hosted Qdrant, gRPC, named/sparse/multivectors, deletion operations, aliases, snapshots, payload indexes, and arbitrary endpoint proxying. --------- Co-authored-by: l1shen <648952316@qq.com>
1 parent ee067c6 commit d07e27b

5 files changed

Lines changed: 1017 additions & 0 deletions

File tree

src/providers/qdrant/actions.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { ProviderActionDefinition } from "../../core/provider-definition.ts";
2+
3+
import { s } from "../../core/json-schema.ts";
4+
import { defineProviderAction } from "../../core/provider-definition.ts";
5+
6+
const service = "qdrant";
7+
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+
);
25+
const vectorSchema = s.array("A dense unnamed vector.", s.number("One vector component."), { minItems: 1 });
26+
const payloadSchema = s.looseObject("A JSON object stored with the point.");
27+
const filterSchema = s.looseObject("A Qdrant filter. Nested conditions are validated by Qdrant.", {
28+
must: s.unknown("Conditions that must match."),
29+
must_not: s.unknown("Conditions that must not match."),
30+
should: s.unknown("Conditions where at least one should 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+
}),
35+
});
36+
37+
const pointSchema = s.object(
38+
"A Qdrant point to insert or update.",
39+
{
40+
id: pointIdSchema,
41+
vector: vectorSchema,
42+
payload: payloadSchema,
43+
},
44+
{ optional: ["payload"] },
45+
);
46+
47+
const recordSchema = s.looseRequiredObject(
48+
"A Qdrant point record.",
49+
{
50+
id: pointIdSchema,
51+
payload: s.nullable(payloadSchema),
52+
vector: s.nullable(vectorSchema),
53+
shard_key: s.unknown("The Qdrant shard key when present."),
54+
order_value: s.unknown("The Qdrant order value when present."),
55+
},
56+
{ optional: ["payload", "vector", "shard_key", "order_value"] },
57+
);
58+
59+
const scoredPointSchema = s.looseRequiredObject(
60+
"A Qdrant scored point.",
61+
{
62+
id: pointIdSchema,
63+
version: s.nonNegativeInteger("The point version."),
64+
score: s.number("The similarity score."),
65+
payload: s.nullable(payloadSchema),
66+
vector: s.nullable(vectorSchema),
67+
shard_key: s.unknown("The Qdrant shard key when present."),
68+
order_value: s.unknown("The Qdrant order value when present."),
69+
},
70+
{ optional: ["payload", "vector", "shard_key", "order_value"] },
71+
);
72+
73+
const collectionSchema = s.looseRequiredObject(
74+
"A Qdrant collection description.",
75+
{
76+
status: s.unknown("The collection status."),
77+
optimizer_status: s.unknown("The collection optimizer status."),
78+
indexed_vectors_count: s.nullable(s.nonNegativeInteger("The approximate indexed vector count.")),
79+
points_count: s.nullable(s.nonNegativeInteger("The approximate point count.")),
80+
segments_count: s.nonNegativeInteger("The number of collection segments."),
81+
config: s.looseObject("The collection configuration."),
82+
payload_schema: s.looseObject("The collection payload index schema."),
83+
warnings: s.array("Collection warnings.", s.looseObject("A collection warning.")),
84+
},
85+
{ optional: ["indexed_vectors_count", "points_count", "warnings"] },
86+
);
87+
88+
const filterInputFields = {
89+
filter: filterSchema,
90+
limit: s.integer("The maximum number of points to return.", { minimum: 1, maximum: 1000 }),
91+
withPayload: s.boolean("Whether to include point payloads."),
92+
withVector: s.boolean("Whether to include point vectors."),
93+
};
94+
95+
export const qdrantActions: ProviderActionDefinition[] = [
96+
defineProviderAction(service, {
97+
name: "list_collections",
98+
description: "List the Qdrant collections visible to the authenticated API key.",
99+
inputSchema: s.actionInput({}, [], "Input parameters for listing Qdrant collections."),
100+
outputSchema: s.actionOutput(
101+
{
102+
collections: s.array(
103+
"The visible Qdrant collections.",
104+
s.requiredObject("A Qdrant collection name.", { name: s.nonEmptyString("The collection name.") }),
105+
),
106+
},
107+
"The Qdrant collection list response.",
108+
),
109+
followUpActions: ["qdrant.get_collection", "qdrant.create_collection"],
110+
}),
111+
defineProviderAction(service, {
112+
name: "get_collection",
113+
description: "Retrieve configuration and status information for one Qdrant collection.",
114+
inputSchema: s.actionInput(
115+
{ collectionName: collectionNameSchema },
116+
["collectionName"],
117+
"Input parameters for retrieving a Qdrant collection.",
118+
),
119+
outputSchema: s.actionOutput({ collection: collectionSchema }, "The Qdrant collection description response."),
120+
followUpActions: ["qdrant.query_points", "qdrant.scroll_points", "qdrant.upsert_points"],
121+
}),
122+
defineProviderAction(service, {
123+
name: "create_collection",
124+
description: "Create a Qdrant Cloud collection with one unnamed dense vector configuration.",
125+
inputSchema: s.actionInput(
126+
{
127+
collectionName: collectionNameSchema,
128+
vectorSize: s.positiveInteger("The dense vector dimension."),
129+
distance: s.stringEnum("The distance function used by the collection.", [
130+
"Cosine",
131+
"Euclid",
132+
"Dot",
133+
"Manhattan",
134+
]),
135+
},
136+
["collectionName", "vectorSize", "distance"],
137+
"Input parameters for creating a dense-vector Qdrant collection.",
138+
),
139+
outputSchema: s.actionOutput({ created: s.boolean("Whether Qdrant created the collection.") }),
140+
followUpActions: ["qdrant.get_collection", "qdrant.upsert_points"],
141+
}),
142+
defineProviderAction(service, {
143+
name: "upsert_points",
144+
description: "Insert or replace dense-vector points in a Qdrant collection and wait for the write to commit.",
145+
inputSchema: s.actionInput(
146+
{
147+
collectionName: collectionNameSchema,
148+
points: s.array("The points to upsert.", pointSchema, { minItems: 1, maxItems: 1000 }),
149+
},
150+
["collectionName", "points"],
151+
"Input parameters for upserting Qdrant points.",
152+
),
153+
outputSchema: s.actionOutput(
154+
{
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+
),
160+
status: s.stringEnum("The Qdrant write status.", ["acknowledged", "completed", "wait_timeout"]),
161+
},
162+
"The Qdrant upsert operation result.",
163+
),
164+
followUpActions: ["qdrant.get_point", "qdrant.query_points", "qdrant.scroll_points"],
165+
}),
166+
defineProviderAction(service, {
167+
name: "get_point",
168+
description: "Retrieve one point by numeric ID or UUID from a Qdrant collection.",
169+
inputSchema: s.actionInput(
170+
{ collectionName: collectionNameSchema, id: pointIdSchema },
171+
["collectionName", "id"],
172+
"Input parameters for retrieving a Qdrant point.",
173+
),
174+
outputSchema: s.actionOutput({ point: recordSchema }, "The Qdrant point record response."),
175+
}),
176+
defineProviderAction(service, {
177+
name: "query_points",
178+
description: "Search a dense-vector Qdrant collection with an optional payload filter.",
179+
inputSchema: s.actionInput(
180+
{
181+
collectionName: collectionNameSchema,
182+
vector: vectorSchema,
183+
...filterInputFields,
184+
offset: s.nonNegativeInteger("The number of matching points to skip."),
185+
scoreThreshold: s.number("The minimum score a result must have."),
186+
},
187+
["collectionName", "vector"],
188+
"Input parameters for querying Qdrant points.",
189+
),
190+
outputSchema: s.actionOutput(
191+
{ points: s.array("The scored points returned by Qdrant.", scoredPointSchema) },
192+
"The Qdrant query response.",
193+
),
194+
followUpActions: ["qdrant.get_point", "qdrant.scroll_points"],
195+
}),
196+
defineProviderAction(service, {
197+
name: "scroll_points",
198+
description: "Read one page of points from a Qdrant collection with an optional payload filter.",
199+
inputSchema: s.actionInput(
200+
{ collectionName: collectionNameSchema, offset: pointIdSchema, ...filterInputFields },
201+
["collectionName"],
202+
"Input parameters for scrolling through Qdrant points.",
203+
),
204+
outputSchema: s.actionOutput(
205+
{
206+
points: s.array("The point records returned by Qdrant.", recordSchema),
207+
nextOffset: s.nullable(pointIdSchema),
208+
complete: s.boolean("Whether there is no next page."),
209+
},
210+
"One page of Qdrant scroll results.",
211+
),
212+
followUpActions: ["qdrant.scroll_points", "qdrant.get_point"],
213+
}),
214+
];

src/providers/qdrant/definition.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { qdrantActions } from "./actions.ts";
4+
5+
const service = "qdrant";
6+
7+
/**
8+
* Qdrant Cloud provider backed by the official REST API.
9+
*/
10+
export const provider: ProviderDefinition = {
11+
service,
12+
displayName: "Qdrant Cloud",
13+
description: "Create, inspect, write, and query dense-vector collections in Qdrant Cloud.",
14+
categories: ["Data", "Developer Tools"],
15+
authTypes: ["custom_credential"],
16+
auth: [
17+
{
18+
type: "custom_credential",
19+
fields: [
20+
{
21+
key: "clusterUrl",
22+
label: "Cluster URL",
23+
inputType: "text",
24+
required: true,
25+
secret: false,
26+
placeholder: "https://your-cluster.cloud.qdrant.io",
27+
description:
28+
"The HTTPS REST URL for a Qdrant Cloud cluster. Only official cloud.qdrant.io endpoints on port 443 or 6333 are supported.",
29+
},
30+
{
31+
key: "apiKey",
32+
label: "API Key",
33+
inputType: "password",
34+
required: true,
35+
secret: true,
36+
placeholder: "Your Qdrant Cloud database API key",
37+
description: "A Qdrant Cloud database API key with access to the target collections.",
38+
},
39+
],
40+
},
41+
],
42+
homepageUrl: "https://qdrant.tech/cloud/",
43+
actions: qdrantActions,
44+
};

src/providers/qdrant/executors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { CredentialValidators, ExecutionContext, ProviderExecutors } from "../../core/types.ts";
2+
3+
import { defineProviderExecutors, requireCustomCredential } from "../provider-runtime.ts";
4+
import { createQdrantContext, qdrantActionHandlers, validateQdrantCredential } from "./runtime.ts";
5+
6+
const service = "qdrant";
7+
8+
export const executors: ProviderExecutors = defineProviderExecutors({
9+
service,
10+
handlers: qdrantActionHandlers,
11+
async createContext(context: ExecutionContext, fetcher): Promise<ReturnType<typeof createQdrantContext>> {
12+
const credential = await requireCustomCredential(context, service);
13+
return createQdrantContext(credential.values, fetcher, context.signal);
14+
},
15+
});
16+
17+
export const credentialValidators: CredentialValidators = {
18+
customCredential(input, { fetcher, signal }) {
19+
return validateQdrantCredential(input.values, fetcher, signal);
20+
},
21+
};

0 commit comments

Comments
 (0)