@wei840222/qmd 2026.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1373 -0
- package/LICENSE +45 -0
- package/README.md +1439 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/qmd +192 -0
- package/dist/ast.d.ts +65 -0
- package/dist/ast.js +334 -0
- package/dist/bench/bench.d.ts +35 -0
- package/dist/bench/bench.js +338 -0
- package/dist/bench/cjk-baseline.d.ts +36 -0
- package/dist/bench/cjk-baseline.js +111 -0
- package/dist/bench/fixture.d.ts +2 -0
- package/dist/bench/fixture.js +84 -0
- package/dist/bench/score.d.ts +38 -0
- package/dist/bench/score.js +107 -0
- package/dist/bench/types.d.ts +110 -0
- package/dist/bench/types.js +8 -0
- package/dist/cli/build-info.json +4 -0
- package/dist/cli/embed-lock.d.ts +24 -0
- package/dist/cli/embed-lock.js +94 -0
- package/dist/cli/embedding-owner.d.ts +10 -0
- package/dist/cli/embedding-owner.js +20 -0
- package/dist/cli/formatter.d.ts +120 -0
- package/dist/cli/formatter.js +355 -0
- package/dist/cli/mcp-pid.d.ts +25 -0
- package/dist/cli/mcp-pid.js +86 -0
- package/dist/cli/qmd.d.ts +72 -0
- package/dist/cli/qmd.js +4806 -0
- package/dist/cli/version.d.ts +42 -0
- package/dist/cli/version.js +80 -0
- package/dist/collections.d.ts +200 -0
- package/dist/collections.js +433 -0
- package/dist/db.d.ts +65 -0
- package/dist/db.js +143 -0
- package/dist/diagnostics.d.ts +62 -0
- package/dist/diagnostics.js +260 -0
- package/dist/embedding/config.d.ts +52 -0
- package/dist/embedding/config.js +229 -0
- package/dist/embedding/identity.d.ts +58 -0
- package/dist/embedding/identity.js +321 -0
- package/dist/embedding/local-identity.d.ts +1 -0
- package/dist/embedding/local-identity.js +15 -0
- package/dist/embedding/local.d.ts +34 -0
- package/dist/embedding/local.js +290 -0
- package/dist/embedding/openai.d.ts +79 -0
- package/dist/embedding/openai.js +477 -0
- package/dist/embedding/owner.d.ts +13 -0
- package/dist/embedding/owner.js +36 -0
- package/dist/embedding/provider.d.ts +68 -0
- package/dist/embedding/provider.js +16 -0
- package/dist/embedding/remote-chunking.d.ts +22 -0
- package/dist/embedding/remote-chunking.js +83 -0
- package/dist/embedding/remote-embedding.d.ts +15 -0
- package/dist/embedding/remote-embedding.js +77 -0
- package/dist/hybrid-llm.d.ts +18 -0
- package/dist/hybrid-llm.js +53 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +418 -0
- package/dist/llm.d.ts +566 -0
- package/dist/llm.js +1847 -0
- package/dist/maintenance.d.ts +33 -0
- package/dist/maintenance.js +52 -0
- package/dist/mcp/origin-guard.d.ts +67 -0
- package/dist/mcp/origin-guard.js +137 -0
- package/dist/mcp/server.d.ts +116 -0
- package/dist/mcp/server.js +919 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +4 -0
- package/dist/remote-llm.d.ts +52 -0
- package/dist/remote-llm.js +464 -0
- package/dist/search/cjk-analyzer.d.ts +33 -0
- package/dist/search/cjk-analyzer.js +158 -0
- package/dist/search/cjk-index.d.ts +104 -0
- package/dist/search/cjk-index.js +1031 -0
- package/dist/search/jieba-loader.d.ts +23 -0
- package/dist/search/jieba-loader.js +79 -0
- package/dist/search/query-expansion.d.ts +23 -0
- package/dist/search/query-expansion.js +43 -0
- package/dist/search/zh-dict.txt +624013 -0
- package/dist/store.d.ts +1218 -0
- package/dist/store.js +6076 -0
- package/dist/trust.d.ts +152 -0
- package/dist/trust.js +249 -0
- package/package.json +139 -0
- package/scripts/build.mjs +83 -0
- package/scripts/check-package-grammars.mjs +29 -0
- package/scripts/package-smoke.mjs +205 -0
- package/scripts/sync-zh-dict.mjs +187 -0
- package/scripts/test-all.mjs +45 -0
- package/skills/qmd/SKILL.md +324 -0
- package/skills/qmd/references/mcp-setup.md +119 -0
- package/skills/release/SKILL.md +141 -0
- package/skills/release/scripts/install-hooks.sh +38 -0
- package/skills/release/scripts/release-context.sh +129 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type OpenAIEmbeddingModel } from "./config.js";
|
|
2
|
+
import { type EmbeddingOperationOptions, type EmbeddingProvider, type RemoteEmbeddingRequestGuard, type EmbeddingVector } from "./provider.js";
|
|
3
|
+
export interface OpenAIEmbeddingUsage {
|
|
4
|
+
readonly promptTokens: number;
|
|
5
|
+
readonly totalTokens: number;
|
|
6
|
+
}
|
|
7
|
+
export interface OpenAIEmbeddingProviderOptions {
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
model?: OpenAIEmbeddingModel;
|
|
10
|
+
dimension?: number;
|
|
11
|
+
/** Override the base URL. Falls back to OPENAI_BASE_URL env or the official OpenAI endpoint. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
maxAttempts?: number;
|
|
14
|
+
fetch?: typeof globalThis.fetch;
|
|
15
|
+
sleep?: (delayMs: number, signal: AbortSignal) => Promise<void>;
|
|
16
|
+
random?: () => number;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
baseRetryDelayMs?: number;
|
|
19
|
+
maxRetryDelayMs?: number;
|
|
20
|
+
/** Internal per-attempt timeout test seam. This is deliberately not part of public qmd config. */
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
/** Mandatory fail-closed policy boundary, invoked immediately before every fetch attempt. */
|
|
23
|
+
authorizeRequest?: RemoteEmbeddingRequestGuard;
|
|
24
|
+
}
|
|
25
|
+
export declare function canonicalOpenAIEmbeddingIdentityMaterial(model?: OpenAIEmbeddingModel, dimension?: number, baseUrl?: string | undefined): string;
|
|
26
|
+
export declare class UnavailableOpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
27
|
+
readonly providerId = "openai";
|
|
28
|
+
readonly model: OpenAIEmbeddingModel;
|
|
29
|
+
readonly dimension: number;
|
|
30
|
+
readonly remote = true;
|
|
31
|
+
private readonly configuredBaseUrl;
|
|
32
|
+
constructor(options?: {
|
|
33
|
+
model?: OpenAIEmbeddingModel;
|
|
34
|
+
dimension?: number;
|
|
35
|
+
baseUrl?: string;
|
|
36
|
+
});
|
|
37
|
+
canonicalIdentityMaterial(): string;
|
|
38
|
+
formatQuery(query: string): string;
|
|
39
|
+
formatDocument(text: string, title?: string): string;
|
|
40
|
+
estimateTokens(text: string): number;
|
|
41
|
+
embed(_text: string, _options: EmbeddingOperationOptions): Promise<EmbeddingVector>;
|
|
42
|
+
embedBatch(_texts: string[], _options: EmbeddingOperationOptions): Promise<EmbeddingVector[]>;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
46
|
+
readonly providerId = "openai";
|
|
47
|
+
readonly model: OpenAIEmbeddingModel;
|
|
48
|
+
readonly dimension: number;
|
|
49
|
+
readonly remote = true;
|
|
50
|
+
private readonly apiKey;
|
|
51
|
+
private readonly baseUrl;
|
|
52
|
+
private readonly fetchImpl;
|
|
53
|
+
private readonly maxAttempts;
|
|
54
|
+
private readonly sleep;
|
|
55
|
+
private readonly random;
|
|
56
|
+
private readonly now;
|
|
57
|
+
private readonly baseRetryDelayMs;
|
|
58
|
+
private readonly maxRetryDelayMs;
|
|
59
|
+
private readonly requestTimeoutMs;
|
|
60
|
+
private readonly authorizeRequest?;
|
|
61
|
+
private readonly fingerprint;
|
|
62
|
+
private readonly closeController;
|
|
63
|
+
private requestTail;
|
|
64
|
+
private closePromise;
|
|
65
|
+
private closed;
|
|
66
|
+
constructor(options: OpenAIEmbeddingProviderOptions);
|
|
67
|
+
canonicalIdentityMaterial(): string;
|
|
68
|
+
canonicalIdentityMaterialForDimension(dimension: number): string;
|
|
69
|
+
formatQuery(query: string): string;
|
|
70
|
+
formatDocument(text: string, title?: string): string;
|
|
71
|
+
/** Conservative token upper bound: every token contains at least one UTF-8 byte. */
|
|
72
|
+
estimateTokens(text: string): number;
|
|
73
|
+
private lifecycleError;
|
|
74
|
+
private throwIfInterrupted;
|
|
75
|
+
private acquireRequestSlot;
|
|
76
|
+
embed(text: string, options: EmbeddingOperationOptions): Promise<EmbeddingVector>;
|
|
77
|
+
embedBatch(texts: string[], options: EmbeddingOperationOptions): Promise<EmbeddingVector[]>;
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { OPENAI_EMBEDDING_DIMENSION, OPENAI_EMBEDDING_MODEL, OPENAI_EMBEDDING_MODELS, DEFAULT_OPENAI_BASE_URL, } from "./config.js";
|
|
3
|
+
import { EmbeddingProviderError, } from "./provider.js";
|
|
4
|
+
import { canonicalRemoteChunkProfile } from "./remote-chunking.js";
|
|
5
|
+
const MAX_INPUTS_PER_REQUEST = 128;
|
|
6
|
+
const MAX_INPUT_TOKEN_UPPER_BOUND = 8_192;
|
|
7
|
+
const MAX_BATCH_TOKEN_UPPER_BOUND = 300_000;
|
|
8
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
const utf8Encoder = new TextEncoder();
|
|
10
|
+
function normalizeOpenAIBaseUrl(baseUrl) {
|
|
11
|
+
return (baseUrl?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
|
|
12
|
+
}
|
|
13
|
+
function endpointFingerprint(baseUrl) {
|
|
14
|
+
if (baseUrl === DEFAULT_OPENAI_BASE_URL)
|
|
15
|
+
return undefined;
|
|
16
|
+
return createHash("sha256").update(baseUrl).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
export function canonicalOpenAIEmbeddingIdentityMaterial(model = OPENAI_EMBEDDING_MODEL, dimension, baseUrl = undefined) {
|
|
19
|
+
const expectedDimension = OPENAI_EMBEDDING_MODELS.get(model);
|
|
20
|
+
const effectiveDimension = dimension ?? expectedDimension ?? OPENAI_EMBEDDING_DIMENSION;
|
|
21
|
+
if (expectedDimension !== undefined && effectiveDimension !== expectedDimension) {
|
|
22
|
+
throw new EmbeddingProviderError("DIMENSION_MISMATCH", `OpenAI embedding dimension must be ${expectedDimension} for model ${model}.`);
|
|
23
|
+
}
|
|
24
|
+
const endpoint = endpointFingerprint(normalizeOpenAIBaseUrl(baseUrl));
|
|
25
|
+
return JSON.stringify({
|
|
26
|
+
provider: "openai",
|
|
27
|
+
model,
|
|
28
|
+
dimension: effectiveDimension,
|
|
29
|
+
remote: true,
|
|
30
|
+
format: "qmd-openai-embedding-v1",
|
|
31
|
+
chunking: canonicalRemoteChunkProfile(),
|
|
32
|
+
...(endpoint === undefined ? {} : { endpointFingerprint: endpoint }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export class UnavailableOpenAIEmbeddingProvider {
|
|
36
|
+
providerId = "openai";
|
|
37
|
+
model;
|
|
38
|
+
dimension;
|
|
39
|
+
remote = true;
|
|
40
|
+
configuredBaseUrl;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.model = options?.model ?? OPENAI_EMBEDDING_MODEL;
|
|
43
|
+
this.dimension = options?.dimension ?? (OPENAI_EMBEDDING_MODELS.get(this.model) ?? OPENAI_EMBEDDING_DIMENSION);
|
|
44
|
+
this.configuredBaseUrl = options?.baseUrl;
|
|
45
|
+
}
|
|
46
|
+
canonicalIdentityMaterial() {
|
|
47
|
+
return canonicalOpenAIEmbeddingIdentityMaterial(this.model, this.dimension, this.configuredBaseUrl ?? process.env.OPENAI_BASE_URL);
|
|
48
|
+
}
|
|
49
|
+
formatQuery(query) {
|
|
50
|
+
return query;
|
|
51
|
+
}
|
|
52
|
+
formatDocument(text, title) {
|
|
53
|
+
return title ? `${title}\n${text}` : text;
|
|
54
|
+
}
|
|
55
|
+
estimateTokens(text) {
|
|
56
|
+
return utf8Encoder.encode(text).byteLength;
|
|
57
|
+
}
|
|
58
|
+
async embed(_text, _options) {
|
|
59
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding provider is not available (missing API key or configuration).");
|
|
60
|
+
}
|
|
61
|
+
async embedBatch(_texts, _options) {
|
|
62
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding provider is not available (missing API key or configuration).");
|
|
63
|
+
}
|
|
64
|
+
async close() { }
|
|
65
|
+
}
|
|
66
|
+
function isRecord(value) {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
function defaultSleep(delayMs, signal) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (signal.aborted) {
|
|
72
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const timer = setTimeout(resolve, delayMs);
|
|
76
|
+
signal.addEventListener("abort", () => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
79
|
+
}, { once: true });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function awaitWithSignal(value, signal) {
|
|
83
|
+
if (signal.aborted) {
|
|
84
|
+
return Promise.reject(new DOMException("aborted", "AbortError"));
|
|
85
|
+
}
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const onAbort = () => reject(new DOMException("aborted", "AbortError"));
|
|
88
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
89
|
+
Promise.resolve(value).then(resolve, reject).finally(() => {
|
|
90
|
+
signal.removeEventListener("abort", onAbort);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function waitForTurn(previous, signal) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
if (signal.aborted) {
|
|
97
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const onAbort = () => {
|
|
101
|
+
signal.removeEventListener("abort", onAbort);
|
|
102
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
103
|
+
};
|
|
104
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
previous.then(() => {
|
|
106
|
+
signal.removeEventListener("abort", onAbort);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function parseRetryAfter(value, now) {
|
|
112
|
+
if (value === null)
|
|
113
|
+
return null;
|
|
114
|
+
const seconds = Number(value);
|
|
115
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
116
|
+
return seconds * 1_000;
|
|
117
|
+
const date = Date.parse(value);
|
|
118
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : null;
|
|
119
|
+
}
|
|
120
|
+
function parseResponse(value, inputCount, tokenUpperBound, expectedModel = OPENAI_EMBEDDING_MODEL, expectedDimension = OPENAI_EMBEDDING_DIMENSION) {
|
|
121
|
+
if (!isRecord(value) || value.object !== "list" || value.model !== expectedModel) {
|
|
122
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding response schema is invalid.");
|
|
123
|
+
}
|
|
124
|
+
if (!isRecord(value.usage)) {
|
|
125
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding usage is invalid.");
|
|
126
|
+
}
|
|
127
|
+
const promptTokens = value.usage.prompt_tokens;
|
|
128
|
+
const totalTokens = value.usage.total_tokens;
|
|
129
|
+
if (!Number.isSafeInteger(promptTokens) || promptTokens < 0
|
|
130
|
+
|| !Number.isSafeInteger(totalTokens) || totalTokens < 0
|
|
131
|
+
|| totalTokens < promptTokens
|
|
132
|
+
|| promptTokens > tokenUpperBound
|
|
133
|
+
|| totalTokens > tokenUpperBound) {
|
|
134
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding usage is invalid.");
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(value.data) || value.data.length !== inputCount) {
|
|
137
|
+
throw new EmbeddingProviderError("BATCH_CARDINALITY_MISMATCH", "OpenAI embedding response cardinality is invalid.");
|
|
138
|
+
}
|
|
139
|
+
const byIndex = new Array(inputCount);
|
|
140
|
+
for (const item of value.data) {
|
|
141
|
+
if (!isRecord(item) || item.object !== "embedding") {
|
|
142
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding item schema is invalid.");
|
|
143
|
+
}
|
|
144
|
+
const index = item.index;
|
|
145
|
+
const embedding = item.embedding;
|
|
146
|
+
if (!Number.isInteger(index) || index < 0 || index >= inputCount) {
|
|
147
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding response index is invalid.");
|
|
148
|
+
}
|
|
149
|
+
if (byIndex[index]) {
|
|
150
|
+
throw new EmbeddingProviderError("RESPONSE_SCHEMA_INVALID", "OpenAI embedding response index is duplicated.");
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(embedding)
|
|
153
|
+
|| embedding.length !== expectedDimension
|
|
154
|
+
|| embedding.some(value => typeof value !== "number"
|
|
155
|
+
|| !Number.isFinite(value)
|
|
156
|
+
|| !Number.isFinite(Math.fround(value)))) {
|
|
157
|
+
throw new EmbeddingProviderError("DIMENSION_MISMATCH", "OpenAI embedding vector is invalid.");
|
|
158
|
+
}
|
|
159
|
+
byIndex[index] = {
|
|
160
|
+
object: "embedding",
|
|
161
|
+
index: index,
|
|
162
|
+
embedding: embedding,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
object: "list",
|
|
167
|
+
model: expectedModel,
|
|
168
|
+
data: byIndex,
|
|
169
|
+
usage: {
|
|
170
|
+
prompt_tokens: promptTokens,
|
|
171
|
+
total_tokens: totalTokens,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export class OpenAIEmbeddingProvider {
|
|
176
|
+
providerId = "openai";
|
|
177
|
+
model;
|
|
178
|
+
dimension;
|
|
179
|
+
remote = true;
|
|
180
|
+
apiKey;
|
|
181
|
+
baseUrl;
|
|
182
|
+
fetchImpl;
|
|
183
|
+
maxAttempts;
|
|
184
|
+
sleep;
|
|
185
|
+
random;
|
|
186
|
+
now;
|
|
187
|
+
baseRetryDelayMs;
|
|
188
|
+
maxRetryDelayMs;
|
|
189
|
+
requestTimeoutMs;
|
|
190
|
+
authorizeRequest;
|
|
191
|
+
fingerprint;
|
|
192
|
+
closeController = new AbortController();
|
|
193
|
+
requestTail = Promise.resolve();
|
|
194
|
+
closePromise = null;
|
|
195
|
+
closed = false;
|
|
196
|
+
constructor(options) {
|
|
197
|
+
const apiKey = options.apiKey?.trim() || undefined;
|
|
198
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
199
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 3) {
|
|
200
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI maxAttempts must be between 1 and 3.");
|
|
201
|
+
}
|
|
202
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
203
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
204
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI requestTimeoutMs must be positive.");
|
|
205
|
+
}
|
|
206
|
+
this.model = options.model ?? OPENAI_EMBEDDING_MODEL;
|
|
207
|
+
this.dimension = options.dimension ?? (OPENAI_EMBEDDING_MODELS.get(this.model) ?? OPENAI_EMBEDDING_DIMENSION);
|
|
208
|
+
this.apiKey = apiKey;
|
|
209
|
+
this.baseUrl = normalizeOpenAIBaseUrl(options.baseUrl ?? process.env.OPENAI_BASE_URL);
|
|
210
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
211
|
+
this.maxAttempts = maxAttempts;
|
|
212
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
213
|
+
this.random = options.random ?? Math.random;
|
|
214
|
+
this.now = options.now ?? Date.now;
|
|
215
|
+
this.baseRetryDelayMs = options.baseRetryDelayMs ?? 250;
|
|
216
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 5_000;
|
|
217
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
218
|
+
this.authorizeRequest = options.authorizeRequest;
|
|
219
|
+
this.fingerprint = createHash("sha256")
|
|
220
|
+
.update(this.canonicalIdentityMaterial())
|
|
221
|
+
.digest("hex");
|
|
222
|
+
}
|
|
223
|
+
canonicalIdentityMaterial() {
|
|
224
|
+
return this.canonicalIdentityMaterialForDimension(this.dimension);
|
|
225
|
+
}
|
|
226
|
+
canonicalIdentityMaterialForDimension(dimension) {
|
|
227
|
+
return canonicalOpenAIEmbeddingIdentityMaterial(this.model, dimension, this.baseUrl);
|
|
228
|
+
}
|
|
229
|
+
formatQuery(query) {
|
|
230
|
+
return query;
|
|
231
|
+
}
|
|
232
|
+
formatDocument(text, title) {
|
|
233
|
+
return title ? `${title}\n${text}` : text;
|
|
234
|
+
}
|
|
235
|
+
/** Conservative token upper bound: every token contains at least one UTF-8 byte. */
|
|
236
|
+
estimateTokens(text) {
|
|
237
|
+
return utf8Encoder.encode(text).byteLength;
|
|
238
|
+
}
|
|
239
|
+
lifecycleError(options, deadlineController) {
|
|
240
|
+
if (this.closed || this.closeController.signal.aborted) {
|
|
241
|
+
return new EmbeddingProviderError("PROVIDER_CLOSED", "Embedding provider is closed.");
|
|
242
|
+
}
|
|
243
|
+
if (options.signal?.aborted) {
|
|
244
|
+
return new EmbeddingProviderError("OPERATION_ABORTED", "Embedding operation was aborted.");
|
|
245
|
+
}
|
|
246
|
+
if (deadlineController.signal.aborted) {
|
|
247
|
+
return new EmbeddingProviderError("DEADLINE_EXCEEDED", "Embedding operation deadline was exceeded.");
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
throwIfInterrupted(options, deadlineController) {
|
|
252
|
+
const error = this.lifecycleError(options, deadlineController);
|
|
253
|
+
if (error)
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
async acquireRequestSlot(signal) {
|
|
257
|
+
const previous = this.requestTail;
|
|
258
|
+
let release;
|
|
259
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
260
|
+
this.requestTail = previous.then(() => gate);
|
|
261
|
+
try {
|
|
262
|
+
await waitForTurn(previous, signal);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
release();
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return release;
|
|
269
|
+
}
|
|
270
|
+
async embed(text, options) {
|
|
271
|
+
const results = await this.embedBatch([text], options);
|
|
272
|
+
return results[0];
|
|
273
|
+
}
|
|
274
|
+
async embedBatch(texts, options) {
|
|
275
|
+
options = Object.freeze({
|
|
276
|
+
purpose: options.purpose,
|
|
277
|
+
kind: options.kind,
|
|
278
|
+
signal: options.signal,
|
|
279
|
+
deadline: options.deadline,
|
|
280
|
+
buildLease: options.buildLease ? Object.freeze({ ...options.buildLease }) : undefined,
|
|
281
|
+
identityFingerprint: options.identityFingerprint,
|
|
282
|
+
});
|
|
283
|
+
if (typeof options.identityFingerprint !== "string" || options.identityFingerprint.length === 0) {
|
|
284
|
+
throw new EmbeddingProviderError("IDENTITY_FINGERPRINT_REQUIRED", "Remote embedding requests require a complete active identity fingerprint.");
|
|
285
|
+
}
|
|
286
|
+
if (this.closed) {
|
|
287
|
+
throw new EmbeddingProviderError("PROVIDER_CLOSED", "Embedding provider is closed.");
|
|
288
|
+
}
|
|
289
|
+
if (options.signal?.aborted) {
|
|
290
|
+
throw new EmbeddingProviderError("OPERATION_ABORTED", "Embedding operation was aborted.");
|
|
291
|
+
}
|
|
292
|
+
if (!Array.isArray(texts) || texts.some(text => typeof text !== "string")) {
|
|
293
|
+
throw new EmbeddingProviderError("INPUT_BUDGET_EXCEEDED", "OpenAI embedding inputs must be strings.");
|
|
294
|
+
}
|
|
295
|
+
const inputs = Object.freeze([...texts]);
|
|
296
|
+
const validPurposeKindPair = (options.purpose === "index-build" && options.kind === "document")
|
|
297
|
+
|| (options.purpose === "query-embedding" && options.kind === "query");
|
|
298
|
+
if (!validPurposeKindPair) {
|
|
299
|
+
throw new EmbeddingProviderError("REMOTE_AUTHORIZATION_REQUIRED", "Remote embedding request purpose does not match its input kind.");
|
|
300
|
+
}
|
|
301
|
+
if (inputs.length === 0 || inputs.length > MAX_INPUTS_PER_REQUEST) {
|
|
302
|
+
throw new EmbeddingProviderError("INPUT_BUDGET_EXCEEDED", `OpenAI embedding batches must contain between 1 and ${MAX_INPUTS_PER_REQUEST} inputs.`);
|
|
303
|
+
}
|
|
304
|
+
let batchUpperBound = 0;
|
|
305
|
+
for (const text of inputs) {
|
|
306
|
+
const upperBound = this.estimateTokens(text);
|
|
307
|
+
if (upperBound === 0 || upperBound > MAX_INPUT_TOKEN_UPPER_BOUND) {
|
|
308
|
+
throw new EmbeddingProviderError("INPUT_BUDGET_EXCEEDED", `OpenAI embedding input exceeds the ${MAX_INPUT_TOKEN_UPPER_BOUND}-token upper bound.`);
|
|
309
|
+
}
|
|
310
|
+
batchUpperBound += upperBound;
|
|
311
|
+
}
|
|
312
|
+
if (batchUpperBound > MAX_BATCH_TOKEN_UPPER_BOUND) {
|
|
313
|
+
throw new EmbeddingProviderError("INPUT_BUDGET_EXCEEDED", `OpenAI embedding batch exceeds the ${MAX_BATCH_TOKEN_UPPER_BOUND}-token upper bound.`);
|
|
314
|
+
}
|
|
315
|
+
const deadlineController = new AbortController();
|
|
316
|
+
let deadlineTimer;
|
|
317
|
+
if (options.deadline !== undefined) {
|
|
318
|
+
const deadline = options.deadline;
|
|
319
|
+
const remaining = deadline - this.now();
|
|
320
|
+
if (remaining <= 0) {
|
|
321
|
+
throw new EmbeddingProviderError("DEADLINE_EXCEEDED", "Embedding operation deadline was exceeded.");
|
|
322
|
+
}
|
|
323
|
+
const scheduleDeadline = () => {
|
|
324
|
+
const nextRemaining = deadline - this.now();
|
|
325
|
+
if (nextRemaining <= 0) {
|
|
326
|
+
deadlineController.abort();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
deadlineTimer = setTimeout(scheduleDeadline, Math.min(nextRemaining, 2_147_483_647));
|
|
330
|
+
};
|
|
331
|
+
scheduleDeadline();
|
|
332
|
+
}
|
|
333
|
+
const signals = [this.closeController.signal, deadlineController.signal];
|
|
334
|
+
if (options.signal)
|
|
335
|
+
signals.push(options.signal);
|
|
336
|
+
const signal = AbortSignal.any(signals);
|
|
337
|
+
let releaseRequestSlot;
|
|
338
|
+
try {
|
|
339
|
+
try {
|
|
340
|
+
releaseRequestSlot = await this.acquireRequestSlot(signal);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
344
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "Embedding request queue failed.");
|
|
345
|
+
}
|
|
346
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
347
|
+
const requestBody = JSON.stringify({
|
|
348
|
+
input: inputs,
|
|
349
|
+
model: this.model,
|
|
350
|
+
dimensions: this.dimension,
|
|
351
|
+
encoding_format: "float",
|
|
352
|
+
});
|
|
353
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
354
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
355
|
+
if (!this.authorizeRequest) {
|
|
356
|
+
throw new EmbeddingProviderError("REMOTE_AUTHORIZATION_REQUIRED", "Remote embedding request authorization is not configured.");
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await awaitWithSignal(this.authorizeRequest({
|
|
360
|
+
fingerprint: options.identityFingerprint,
|
|
361
|
+
purpose: options.purpose,
|
|
362
|
+
kind: options.kind,
|
|
363
|
+
attempt,
|
|
364
|
+
buildLease: options.buildLease,
|
|
365
|
+
}), signal);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
372
|
+
const requestController = new AbortController();
|
|
373
|
+
const requestTimer = setTimeout(() => requestController.abort(), this.requestTimeoutMs);
|
|
374
|
+
const requestSignal = AbortSignal.any([signal, requestController.signal]);
|
|
375
|
+
let response = null;
|
|
376
|
+
let responseBodyConsumed = false;
|
|
377
|
+
try {
|
|
378
|
+
try {
|
|
379
|
+
response = await this.fetchImpl(`${this.baseUrl}/embeddings`, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: {
|
|
382
|
+
...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),
|
|
383
|
+
"content-type": "application/json",
|
|
384
|
+
},
|
|
385
|
+
body: requestBody,
|
|
386
|
+
signal: requestSignal,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
391
|
+
}
|
|
392
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
393
|
+
if (requestController.signal.aborted)
|
|
394
|
+
response = null;
|
|
395
|
+
if (response?.ok) {
|
|
396
|
+
let body;
|
|
397
|
+
let bodyRead = false;
|
|
398
|
+
try {
|
|
399
|
+
body = await response.json();
|
|
400
|
+
bodyRead = true;
|
|
401
|
+
responseBodyConsumed = true;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
405
|
+
if (!requestController.signal.aborted && error instanceof SyntaxError) {
|
|
406
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding response is not valid JSON.");
|
|
407
|
+
}
|
|
408
|
+
response = null;
|
|
409
|
+
}
|
|
410
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
411
|
+
if (requestController.signal.aborted)
|
|
412
|
+
response = null;
|
|
413
|
+
if (bodyRead && response) {
|
|
414
|
+
const parsed = parseResponse(body, inputs.length, batchUpperBound, this.model, this.dimension);
|
|
415
|
+
const usage = Object.freeze({
|
|
416
|
+
promptTokens: parsed.usage.prompt_tokens,
|
|
417
|
+
totalTokens: parsed.usage.total_tokens,
|
|
418
|
+
});
|
|
419
|
+
return parsed.data.map(item => ({
|
|
420
|
+
vector: item.embedding,
|
|
421
|
+
model: parsed.model,
|
|
422
|
+
dimension: item.embedding.length,
|
|
423
|
+
usage,
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
clearTimeout(requestTimer);
|
|
430
|
+
if (response?.body && !responseBodyConsumed) {
|
|
431
|
+
void response.body.cancel().catch(() => { });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const status = response?.status;
|
|
435
|
+
const transient = status === undefined || status === 408 || status === 429 || status >= 500;
|
|
436
|
+
if (!transient) {
|
|
437
|
+
throw new EmbeddingProviderError("HTTP_TERMINAL", `OpenAI embedding request failed with HTTP ${status}.`);
|
|
438
|
+
}
|
|
439
|
+
if (attempt === this.maxAttempts) {
|
|
440
|
+
throw new EmbeddingProviderError("RETRY_EXHAUSTED", "OpenAI embedding request exhausted its retry budget.");
|
|
441
|
+
}
|
|
442
|
+
const retryAfter = response
|
|
443
|
+
? parseRetryAfter(response.headers.get("retry-after"), this.now())
|
|
444
|
+
: null;
|
|
445
|
+
const exponential = this.baseRetryDelayMs * (2 ** (attempt - 1));
|
|
446
|
+
const jittered = exponential * (0.5 + (this.random() * 0.5));
|
|
447
|
+
const delayMs = Math.min(retryAfter ?? jittered, this.maxRetryDelayMs);
|
|
448
|
+
if (options.deadline !== undefined && this.now() + delayMs >= options.deadline) {
|
|
449
|
+
throw new EmbeddingProviderError("DEADLINE_EXCEEDED", "Embedding operation deadline was exceeded.");
|
|
450
|
+
}
|
|
451
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
452
|
+
try {
|
|
453
|
+
await this.sleep(delayMs, signal);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
457
|
+
throw new EmbeddingProviderError("PROVIDER_FAILURE", "OpenAI embedding retry delay failed.");
|
|
458
|
+
}
|
|
459
|
+
this.throwIfInterrupted(options, deadlineController);
|
|
460
|
+
}
|
|
461
|
+
throw new EmbeddingProviderError("RETRY_EXHAUSTED", "OpenAI embedding request exhausted its retry budget.");
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
if (deadlineTimer !== undefined)
|
|
465
|
+
clearTimeout(deadlineTimer);
|
|
466
|
+
releaseRequestSlot?.();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async close() {
|
|
470
|
+
if (!this.closePromise) {
|
|
471
|
+
this.closed = true;
|
|
472
|
+
this.closeController.abort();
|
|
473
|
+
this.closePromise = this.requestTail;
|
|
474
|
+
}
|
|
475
|
+
await this.closePromise;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EmbeddingProvider, EmbeddingProviderOwner } from "./provider.js";
|
|
2
|
+
export interface DisposableEmbeddingRuntime {
|
|
3
|
+
dispose(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
/** Owns a provider and the runtime it depends on. */
|
|
6
|
+
export declare class CompositeEmbeddingProviderOwner implements EmbeddingProviderOwner {
|
|
7
|
+
readonly provider: EmbeddingProvider;
|
|
8
|
+
private readonly runtime;
|
|
9
|
+
private closePromise;
|
|
10
|
+
constructor(provider: EmbeddingProvider, runtime: DisposableEmbeddingRuntime);
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
private closeResources;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Owns a provider and the runtime it depends on. */
|
|
2
|
+
export class CompositeEmbeddingProviderOwner {
|
|
3
|
+
provider;
|
|
4
|
+
runtime;
|
|
5
|
+
closePromise = null;
|
|
6
|
+
constructor(provider, runtime) {
|
|
7
|
+
this.provider = provider;
|
|
8
|
+
this.runtime = runtime;
|
|
9
|
+
}
|
|
10
|
+
close() {
|
|
11
|
+
if (!this.closePromise) {
|
|
12
|
+
this.closePromise = this.closeResources();
|
|
13
|
+
}
|
|
14
|
+
return this.closePromise;
|
|
15
|
+
}
|
|
16
|
+
async closeResources() {
|
|
17
|
+
let providerError;
|
|
18
|
+
try {
|
|
19
|
+
await this.provider.close();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
providerError = error;
|
|
23
|
+
}
|
|
24
|
+
let runtimeError;
|
|
25
|
+
try {
|
|
26
|
+
await this.runtime.dispose();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
runtimeError = error;
|
|
30
|
+
}
|
|
31
|
+
if (providerError !== undefined)
|
|
32
|
+
throw providerError;
|
|
33
|
+
if (runtimeError !== undefined)
|
|
34
|
+
throw runtimeError;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EmbeddingBuildLease } from "./identity.js";
|
|
2
|
+
export type EmbeddingInputKind = "query" | "document";
|
|
3
|
+
export type EmbeddingRequestPurpose = "capability-probe" | "index-build" | "query-embedding";
|
|
4
|
+
export declare const REMOTE_CAPABILITY_PROBE_SENTINEL = "qmd-remote-capability-probe-v1";
|
|
5
|
+
export type EmbeddingProviderErrorCode = "PROVIDER_CLOSED" | "OPERATION_ABORTED" | "DEADLINE_EXCEEDED" | "PROVIDER_FAILURE" | "MISSING_EMBEDDING" | "EMPTY_VECTOR" | "NON_FINITE_VECTOR" | "MODEL_MISMATCH" | "DIMENSION_UNKNOWN" | "DIMENSION_MISMATCH" | "BATCH_CARDINALITY_MISMATCH" | "INPUT_BUDGET_EXCEEDED" | "RESPONSE_SCHEMA_INVALID" | "HTTP_TERMINAL" | "RETRY_EXHAUSTED" | "IDENTITY_FINGERPRINT_REQUIRED" | "REMOTE_AUTHORIZATION_REQUIRED";
|
|
6
|
+
export declare class EmbeddingProviderError extends Error {
|
|
7
|
+
readonly code: EmbeddingProviderErrorCode;
|
|
8
|
+
readonly operation?: "embed" | "embedBatch";
|
|
9
|
+
readonly index?: number;
|
|
10
|
+
/** Native causes are intentionally discarded so provider errors are safe to serialize. */
|
|
11
|
+
readonly cause: undefined;
|
|
12
|
+
constructor(code: EmbeddingProviderErrorCode, message: string, options?: {
|
|
13
|
+
operation?: "embed" | "embedBatch";
|
|
14
|
+
index?: number;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export interface EmbeddingOperationOptions {
|
|
18
|
+
/** Required purpose used by remote policy guards and request auditing. */
|
|
19
|
+
purpose: EmbeddingRequestPurpose;
|
|
20
|
+
/** Whether the caller formatted the input as a query or document. */
|
|
21
|
+
kind?: EmbeddingInputKind;
|
|
22
|
+
/** Cancels the caller's wait for this operation. */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Absolute Unix timestamp in milliseconds after which the operation must fail. */
|
|
25
|
+
deadline?: number;
|
|
26
|
+
/** Complete active build lease required for remote index-build requests. */
|
|
27
|
+
buildLease?: EmbeddingBuildLease;
|
|
28
|
+
/** Full published/build identity fingerprint, including chunking policy. */
|
|
29
|
+
identityFingerprint: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RemoteEmbeddingRequestAuthorization {
|
|
32
|
+
readonly fingerprint: string;
|
|
33
|
+
readonly purpose: EmbeddingRequestPurpose;
|
|
34
|
+
readonly kind?: EmbeddingInputKind;
|
|
35
|
+
readonly attempt: number;
|
|
36
|
+
readonly buildLease?: EmbeddingBuildLease;
|
|
37
|
+
}
|
|
38
|
+
export type RemoteEmbeddingRequestGuard = (request: RemoteEmbeddingRequestAuthorization) => void | Promise<void>;
|
|
39
|
+
export interface EmbeddingVector {
|
|
40
|
+
vector: number[];
|
|
41
|
+
model: string;
|
|
42
|
+
dimension: number;
|
|
43
|
+
/** Provider-reported numeric usage for this operation, when available. */
|
|
44
|
+
readonly usage?: Readonly<{
|
|
45
|
+
promptTokens: number;
|
|
46
|
+
totalTokens: number;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export interface EmbeddingProvider {
|
|
50
|
+
readonly providerId: string;
|
|
51
|
+
readonly model: string;
|
|
52
|
+
readonly dimension: number | null;
|
|
53
|
+
readonly remote: boolean;
|
|
54
|
+
canonicalIdentityMaterial(): string;
|
|
55
|
+
/** Compute identity material from a persisted dimension without loading the model. */
|
|
56
|
+
canonicalIdentityMaterialForDimension?(dimension: number): string;
|
|
57
|
+
formatQuery(query: string): string;
|
|
58
|
+
formatDocument(text: string, title?: string): string;
|
|
59
|
+
embed(text: string, options: EmbeddingOperationOptions): Promise<EmbeddingVector>;
|
|
60
|
+
embedBatch(texts: string[], options: EmbeddingOperationOptions): Promise<EmbeddingVector[]>;
|
|
61
|
+
estimateTokens?(text: string): number;
|
|
62
|
+
close(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/** Owns a provider and every resource needed to service it. */
|
|
65
|
+
export interface EmbeddingProviderOwner {
|
|
66
|
+
readonly provider: EmbeddingProvider;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|