md-semantic-search 1.0.3 → 2.0.0
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/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/dist/models.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Pooling = 'mean' | 'last_token' | 'none';
|
|
1
|
+
export type Pooling = 'mean' | 'last_token' | 'none' | 'cls';
|
|
2
2
|
export type DType = 'q8' | 'q4' | 'fp16' | 'fp32' | 'int8' | 'uint8';
|
|
3
3
|
export interface ModelAdapter {
|
|
4
4
|
id: string;
|
|
@@ -13,6 +13,12 @@ export interface ModelAdapter {
|
|
|
13
13
|
dtype?: DType | string;
|
|
14
14
|
maxTokens?: number;
|
|
15
15
|
sessionOptions?: Record<string, unknown>;
|
|
16
|
+
/**
|
|
17
|
+
* issue #200: the ONNX graph already emits a pooled `sentence_embedding`
|
|
18
|
+
* (e.g. EmbeddingGemma) — embedding bypasses the feature-extraction
|
|
19
|
+
* pipeline and reads that output directly; `pooling: 'none'` is implied.
|
|
20
|
+
*/
|
|
21
|
+
pooledOutput?: boolean;
|
|
16
22
|
family?: string;
|
|
17
23
|
note?: string;
|
|
18
24
|
unknownAdapter?: boolean;
|
|
@@ -21,7 +27,7 @@ export declare const MODELS: Record<string, ModelAdapter>;
|
|
|
21
27
|
export declare const DEFAULT_MODEL = "e5-base";
|
|
22
28
|
export declare const POOLING: Pooling[];
|
|
23
29
|
export declare function normalizeAdapter(raw: Partial<ModelAdapter> | ModelAdapter): ModelAdapter;
|
|
24
|
-
export declare function resolveModel(name?: string | Partial<ModelAdapter> | ModelAdapter | null): ModelAdapter;
|
|
30
|
+
export declare function resolveModel(name?: string | Partial<ModelAdapter> | ModelAdapter | null, chosenDim?: number): ModelAdapter;
|
|
25
31
|
export interface FingerprintInput {
|
|
26
32
|
queryPrefix?: string;
|
|
27
33
|
passagePrefix?: string;
|
package/dist/models.js
CHANGED
|
@@ -46,6 +46,7 @@ export const MODELS = {
|
|
|
46
46
|
id: 'Xenova/bge-m3',
|
|
47
47
|
nativeDim: 1024,
|
|
48
48
|
dim: 1024,
|
|
49
|
+
dimensions: [64, 128, 256, 512, 1024],
|
|
49
50
|
queryPrefix: '',
|
|
50
51
|
passagePrefix: '',
|
|
51
52
|
pooling: 'mean',
|
|
@@ -60,6 +61,7 @@ export const MODELS = {
|
|
|
60
61
|
revision: 'c25a394dd583836952667c12f008335071b3f43d',
|
|
61
62
|
nativeDim: 1024,
|
|
62
63
|
dim: 1024,
|
|
64
|
+
dimensions: [32, 64, 128, 256, 512, 768, 1024],
|
|
63
65
|
queryPrefix: 'Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:',
|
|
64
66
|
passagePrefix: '',
|
|
65
67
|
pooling: 'last_token',
|
|
@@ -67,11 +69,41 @@ export const MODELS = {
|
|
|
67
69
|
dtype: 'q8',
|
|
68
70
|
maxTokens: 32768,
|
|
69
71
|
family: 'qwen3',
|
|
70
|
-
note: '~613 MB q8. Opt-in Qwen3 embedding model.',
|
|
72
|
+
note: '~613 MB q8. Opt-in Qwen3 embedding model with Matryoshka/MRL support.',
|
|
73
|
+
},
|
|
74
|
+
'granite-multilingual-311m': {
|
|
75
|
+
id: 'onnx-community/granite-embedding-311m-multilingual-r2-ONNX',
|
|
76
|
+
revision: '8f039f21d4181327268271bea4b11ddcc7eef88d',
|
|
77
|
+
nativeDim: 768,
|
|
78
|
+
dim: 768,
|
|
79
|
+
queryPrefix: '',
|
|
80
|
+
passagePrefix: '',
|
|
81
|
+
pooling: 'cls',
|
|
82
|
+
normalize: true,
|
|
83
|
+
dtype: 'q8',
|
|
84
|
+
maxTokens: 8192,
|
|
85
|
+
family: 'granite',
|
|
86
|
+
note: '~298 MB q8. IBM Granite r2, ModernBERT (8k context), CLS pooling, Apache-2.0. Issue #200.',
|
|
87
|
+
},
|
|
88
|
+
'embeddinggemma-300m': {
|
|
89
|
+
id: 'onnx-community/embeddinggemma-300m-ONNX',
|
|
90
|
+
revision: '5090578d9565bb06545b4552f76e6bc2c93e4a66',
|
|
91
|
+
nativeDim: 768,
|
|
92
|
+
dim: 768,
|
|
93
|
+
dimensions: [64, 128, 256, 512, 768],
|
|
94
|
+
queryPrefix: 'task: search result | query: ',
|
|
95
|
+
passagePrefix: 'title: none | text: ',
|
|
96
|
+
pooling: 'none',
|
|
97
|
+
normalize: true,
|
|
98
|
+
dtype: 'q8',
|
|
99
|
+
maxTokens: 2048,
|
|
100
|
+
pooledOutput: true,
|
|
101
|
+
family: 'gemma',
|
|
102
|
+
note: '~300 MB q8. Google EmbeddingGemma, pre-pooled sentence_embedding output, MRL. Gemma license (not MIT/Apache). Issue #200.',
|
|
71
103
|
},
|
|
72
104
|
};
|
|
73
105
|
export const DEFAULT_MODEL = 'e5-base';
|
|
74
|
-
export const POOLING = ['mean', 'last_token', 'none'];
|
|
106
|
+
export const POOLING = ['mean', 'last_token', 'none', 'cls'];
|
|
75
107
|
const POOLING_SET = new Set(POOLING);
|
|
76
108
|
const aliasByRepoId = new Map();
|
|
77
109
|
for (const [alias, adapter] of Object.entries(MODELS)) {
|
|
@@ -113,6 +145,7 @@ export function normalizeAdapter(raw) {
|
|
|
113
145
|
normalize: raw.normalize !== false,
|
|
114
146
|
dtype: typeof raw.dtype === 'string' && raw.dtype.length > 0 ? raw.dtype : 'q8',
|
|
115
147
|
...(Number.isSafeInteger(raw.maxTokens) && raw.maxTokens && raw.maxTokens > 0 ? { maxTokens: raw.maxTokens } : {}),
|
|
148
|
+
...(raw.pooledOutput === true ? { pooledOutput: true } : {}),
|
|
116
149
|
...(typeof raw.family === 'string' && raw.family.length > 0 ? { family: raw.family } : {}),
|
|
117
150
|
...(typeof raw.note === 'string' ? { note: raw.note } : {}),
|
|
118
151
|
});
|
|
@@ -121,42 +154,61 @@ export function normalizeAdapter(raw) {
|
|
|
121
154
|
}
|
|
122
155
|
return adapter;
|
|
123
156
|
}
|
|
124
|
-
export function resolveModel(name) {
|
|
157
|
+
export function resolveModel(name, chosenDim) {
|
|
158
|
+
let base;
|
|
125
159
|
if (name === undefined || name === null || name === '') {
|
|
126
|
-
|
|
160
|
+
base = withDim(MODELS[DEFAULT_MODEL]);
|
|
127
161
|
}
|
|
128
|
-
if (typeof name === 'object' && !Array.isArray(name)) {
|
|
129
|
-
|
|
162
|
+
else if (typeof name === 'object' && !Array.isArray(name)) {
|
|
163
|
+
base = normalizeAdapter(name);
|
|
130
164
|
}
|
|
131
|
-
if (typeof name !== 'string') {
|
|
165
|
+
else if (typeof name !== 'string') {
|
|
132
166
|
throw new Error(`model must be an alias, id, or adapter object — got ${typeof name}`);
|
|
133
167
|
}
|
|
134
|
-
if (MODELS[name])
|
|
135
|
-
|
|
136
|
-
let id = name;
|
|
137
|
-
let revision = 'main';
|
|
138
|
-
const at = name.indexOf('@');
|
|
139
|
-
const hasExplicitRevision = at > 0;
|
|
140
|
-
if (hasExplicitRevision) {
|
|
141
|
-
id = name.slice(0, at);
|
|
142
|
-
const requested = name.slice(at + 1);
|
|
143
|
-
revision = requested === '' ? 'main' : requested;
|
|
168
|
+
else if (MODELS[name]) {
|
|
169
|
+
base = withDim(MODELS[name]);
|
|
144
170
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
171
|
+
else {
|
|
172
|
+
let id = name;
|
|
173
|
+
let revision = 'main';
|
|
174
|
+
const at = name.indexOf('@');
|
|
175
|
+
const hasExplicitRevision = at > 0;
|
|
176
|
+
if (hasExplicitRevision) {
|
|
177
|
+
id = name.slice(0, at);
|
|
178
|
+
const requested = name.slice(at + 1);
|
|
179
|
+
revision = requested === '' ? 'main' : requested;
|
|
180
|
+
}
|
|
181
|
+
const alias = aliasByRepoId.get(id);
|
|
182
|
+
if (alias !== undefined) {
|
|
183
|
+
const registered = MODELS[alias];
|
|
184
|
+
const resolved = hasExplicitRevision ? { ...registered, revision } : registered;
|
|
185
|
+
base = withDim(resolved);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
base = normalizeAdapter({
|
|
189
|
+
id,
|
|
190
|
+
...(revision !== 'main' ? { revision } : {}),
|
|
191
|
+
pooling: 'none',
|
|
192
|
+
normalize: false,
|
|
193
|
+
nativeDim: 0,
|
|
194
|
+
dim: 0,
|
|
195
|
+
unknownAdapter: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
150
198
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
if (chosenDim !== undefined) {
|
|
200
|
+
if (typeof chosenDim !== 'number' || !Number.isSafeInteger(chosenDim) || chosenDim <= 0) {
|
|
201
|
+
throw new Error(`--dimensions must be a positive integer, got "${chosenDim}"`);
|
|
202
|
+
}
|
|
203
|
+
if (!base.dimensions || base.dimensions.length === 0) {
|
|
204
|
+
throw new Error(`model "${base.id}" does not declare Matryoshka/MRL dimensions support (supported MRL models: qwen3-embedding-0.6b, bge-m3)`);
|
|
205
|
+
}
|
|
206
|
+
if (!base.dimensions.includes(chosenDim)) {
|
|
207
|
+
throw new Error(`invalid dimension ${chosenDim} for model "${base.id}" — supported MRL dimensions: ${base.dimensions.join(', ')}`);
|
|
208
|
+
}
|
|
209
|
+
return { ...base, dim: chosenDim, nativeDim: base.nativeDim || base.dim };
|
|
210
|
+
}
|
|
211
|
+
return base;
|
|
160
212
|
}
|
|
161
213
|
export function embeddingAdapterFingerprint(model) {
|
|
162
214
|
const semantics = [
|
|
@@ -165,7 +217,7 @@ export function embeddingAdapterFingerprint(model) {
|
|
|
165
217
|
`passage-prefix:${model.passagePrefix ?? ''}`,
|
|
166
218
|
`pooling:${model.pooling ?? 'mean'}`,
|
|
167
219
|
`normalize:${model.normalize !== false}`,
|
|
168
|
-
`dimension:${model.
|
|
220
|
+
`dimension:${model.dim ?? model.nativeDim ?? 0}`,
|
|
169
221
|
].join('\u0000');
|
|
170
222
|
return `adapter-v1:${crypto.createHash('sha256').update(semantics).digest('hex')}`;
|
|
171
223
|
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -19,3 +19,59 @@ export interface ResolvedEmbedder {
|
|
|
19
19
|
}
|
|
20
20
|
/** Build an external embedder; dim is probed once on first use and cached. */
|
|
21
21
|
export declare function resolveExternalEmbedder(cfg: ProviderConfig): ResolvedEmbedder;
|
|
22
|
+
/**
|
|
23
|
+
* Chat provider (issue #199): one OpenAI-compatible `/chat/completions` path
|
|
24
|
+
* for answer synthesis — the same lingua franca HyDE already speaks — with an
|
|
25
|
+
* Ollama-native `/api/generate` legacy path (`--llm-provider ollama`) that
|
|
26
|
+
* preserves the pre-#199 wire format for existing setups.
|
|
27
|
+
*/
|
|
28
|
+
export type ChatProviderId = 'openai' | 'ollama';
|
|
29
|
+
export interface ChatProviderConfig {
|
|
30
|
+
provider: ChatProviderId;
|
|
31
|
+
/** Provider-side model name; omitted bodies let the server pick its default. */
|
|
32
|
+
model?: string;
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
keyFile?: string;
|
|
35
|
+
/** Injected fetch for tests; defaults to globalThis.fetch. */
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
}
|
|
38
|
+
export interface ChatRequest {
|
|
39
|
+
system: string;
|
|
40
|
+
user: string;
|
|
41
|
+
/** Streaming render hook; fires once per token chunk when the wire streams. */
|
|
42
|
+
onDelta?: (delta: string) => void;
|
|
43
|
+
/** Cooperative cancel (serve: client disconnected mid-stream). */
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
export interface ResolvedChat {
|
|
47
|
+
id: ChatProviderId;
|
|
48
|
+
model: string;
|
|
49
|
+
/** Streams one completion; resolves to the assembled full text. */
|
|
50
|
+
complete(req: ChatRequest): Promise<string>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Incremental SSE frame parser (issue #199): feed it raw network chunks, it
|
|
54
|
+
* emits one `data:` payload per complete event. Handles CRLF, LF-only streams,
|
|
55
|
+
* multi-line `data:` fields (joined with \n per the WHATWG spec), comment
|
|
56
|
+
* keep-alive lines (`: ping`), and chunks that split a line or a CRLF pair
|
|
57
|
+
* across the boundary. Non-`data` fields (event/id/retry) are irrelevant to
|
|
58
|
+
* the chat delta stream and dropped.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createSseParser(onData: (data: string) => void): {
|
|
61
|
+
push(chunk: string): void;
|
|
62
|
+
end(): void;
|
|
63
|
+
};
|
|
64
|
+
/** `data:` payload of an OpenAI streaming chunk → delta text ('' when none). */
|
|
65
|
+
export declare function openAiDeltaText(payload: string): string;
|
|
66
|
+
/** One Ollama NDJSON streaming line → delta text ('' when none). */
|
|
67
|
+
export declare function ollamaDeltaText(line: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* Build a chat completion provider over HTTP streaming (issue #199).
|
|
70
|
+
* `openai` → POST {base}/chat/completions with `stream:true`, SSE deltas.
|
|
71
|
+
* `ollama` → POST {base}/api/generate with `stream:true`, NDJSON deltas —
|
|
72
|
+
* the pre-#199 endpoint, upgraded from `stream:false` to streaming. The
|
|
73
|
+
* openai path additionally falls back to /api/generate once when the endpoint
|
|
74
|
+
* answers 404/405 (not OpenAI-shaped), so an Ollama root URL keeps working
|
|
75
|
+
* with default flags.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveChatProvider(cfg: ChatProviderConfig): ResolvedChat;
|
package/dist/providers.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* offline path; providers are opt-in via explicit flags.
|
|
13
13
|
*/
|
|
14
14
|
import { loadApiKeyFile } from './serve.js';
|
|
15
|
+
import { fetchTimeoutMs } from './core.js';
|
|
15
16
|
function baseOf(cfg) {
|
|
16
17
|
const raw = cfg.baseUrl ||
|
|
17
18
|
(cfg.embedder === 'ollama' ? process.env.OLLAMA_HOST || 'http://127.0.0.1:11434' : 'https://api.openai.com/v1');
|
|
@@ -29,6 +30,9 @@ async function postJson(fetchImpl, url, headers, body) {
|
|
|
29
30
|
method: 'POST',
|
|
30
31
|
headers: { 'content-type': 'application/json', ...headers },
|
|
31
32
|
body: JSON.stringify(body),
|
|
33
|
+
// issue #184: a hung embedder endpoint must fail into the retry path,
|
|
34
|
+
// not hold an index run / serve slot for undici's ~300s default
|
|
35
|
+
signal: AbortSignal.timeout(fetchTimeoutMs()),
|
|
32
36
|
});
|
|
33
37
|
if (!res.ok) {
|
|
34
38
|
const text = await res.text().catch(() => '');
|
|
@@ -95,6 +99,10 @@ export function resolveExternalEmbedder(cfg) {
|
|
|
95
99
|
family: cfg.embedder,
|
|
96
100
|
};
|
|
97
101
|
})();
|
|
102
|
+
// The probe starts eagerly; callers that only ever use embedFn never await
|
|
103
|
+
// it, so its rejection (e.g. the issue #184 fetch timeout) must not become
|
|
104
|
+
// an unhandled one. descriptor() re-surfaces the same rejection to awaiters.
|
|
105
|
+
descriptorPromise.catch(() => { });
|
|
98
106
|
return {
|
|
99
107
|
id: cfg.embedder,
|
|
100
108
|
model: cfg.model,
|
|
@@ -102,3 +110,300 @@ export function resolveExternalEmbedder(cfg) {
|
|
|
102
110
|
descriptor: () => descriptorPromise,
|
|
103
111
|
};
|
|
104
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Incremental SSE frame parser (issue #199): feed it raw network chunks, it
|
|
115
|
+
* emits one `data:` payload per complete event. Handles CRLF, LF-only streams,
|
|
116
|
+
* multi-line `data:` fields (joined with \n per the WHATWG spec), comment
|
|
117
|
+
* keep-alive lines (`: ping`), and chunks that split a line or a CRLF pair
|
|
118
|
+
* across the boundary. Non-`data` fields (event/id/retry) are irrelevant to
|
|
119
|
+
* the chat delta stream and dropped.
|
|
120
|
+
*/
|
|
121
|
+
export function createSseParser(onData) {
|
|
122
|
+
let buf = '';
|
|
123
|
+
let dataLines = [];
|
|
124
|
+
const dispatch = () => {
|
|
125
|
+
if (dataLines.length > 0) {
|
|
126
|
+
const payload = dataLines.join('\n');
|
|
127
|
+
dataLines = [];
|
|
128
|
+
onData(payload);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const consumeLine = (rawLine) => {
|
|
132
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
133
|
+
if (line === '') {
|
|
134
|
+
dispatch();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (line.startsWith(':'))
|
|
138
|
+
return; // comment / keep-alive
|
|
139
|
+
if (line.startsWith('data:'))
|
|
140
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
push(chunk) {
|
|
144
|
+
buf += chunk;
|
|
145
|
+
let nl;
|
|
146
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
147
|
+
const line = buf.slice(0, nl);
|
|
148
|
+
buf = buf.slice(nl + 1);
|
|
149
|
+
consumeLine(line);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
end() {
|
|
153
|
+
if (buf !== '') {
|
|
154
|
+
consumeLine(buf);
|
|
155
|
+
buf = '';
|
|
156
|
+
}
|
|
157
|
+
dispatch();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** `data:` payload of an OpenAI streaming chunk → delta text ('' when none). */
|
|
162
|
+
export function openAiDeltaText(payload) {
|
|
163
|
+
if (payload === '[DONE]')
|
|
164
|
+
return '';
|
|
165
|
+
try {
|
|
166
|
+
const json = JSON.parse(payload);
|
|
167
|
+
const text = json?.choices?.[0]?.delta?.content;
|
|
168
|
+
return typeof text === 'string' ? text : '';
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** One Ollama NDJSON streaming line → delta text ('' when none). */
|
|
175
|
+
export function ollamaDeltaText(line) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed)
|
|
178
|
+
return '';
|
|
179
|
+
try {
|
|
180
|
+
const json = JSON.parse(trimmed);
|
|
181
|
+
const text = json?.response;
|
|
182
|
+
return typeof text === 'string' ? text : '';
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function chatBaseOf(cfg) {
|
|
189
|
+
const raw = cfg.baseUrl ||
|
|
190
|
+
(cfg.provider === 'ollama' ? process.env.OLLAMA_HOST || 'http://127.0.0.1:11434' : 'https://api.openai.com/v1');
|
|
191
|
+
return raw.replace(/\/+$/, '');
|
|
192
|
+
}
|
|
193
|
+
function chatBearerKey(cfg) {
|
|
194
|
+
if (cfg.provider !== 'openai')
|
|
195
|
+
return undefined;
|
|
196
|
+
if (cfg.keyFile)
|
|
197
|
+
return loadApiKeyFile(cfg.keyFile);
|
|
198
|
+
return process.env.OPENAI_API_KEY || undefined;
|
|
199
|
+
}
|
|
200
|
+
const IDLE_ABORT_REASON = () => new DOMException('The operation was aborted due to timeout', 'TimeoutError');
|
|
201
|
+
/**
|
|
202
|
+
* Streams one completion with an IDLE timeout (issue #199 × #184): the
|
|
203
|
+
* abort timer arms before connect and resets on every received chunk, so a
|
|
204
|
+
* hung endpoint fails fast while a long streaming generation is never killed
|
|
205
|
+
* mid-answer the way a whole-request `AbortSignal.timeout` would. An external
|
|
206
|
+
* `signal` (serve: client disconnected mid-stream) aborts immediately.
|
|
207
|
+
*/
|
|
208
|
+
function makeIdleGuard(external) {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const idleMs = fetchTimeoutMs();
|
|
211
|
+
let timer = setTimeout(() => controller.abort(IDLE_ABORT_REASON()), idleMs);
|
|
212
|
+
timer?.unref?.();
|
|
213
|
+
const onExternalAbort = () => controller.abort(external.reason);
|
|
214
|
+
if (external) {
|
|
215
|
+
if (external.aborted)
|
|
216
|
+
onExternalAbort();
|
|
217
|
+
else
|
|
218
|
+
external.addEventListener('abort', onExternalAbort, { once: true });
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
signal: controller.signal,
|
|
222
|
+
/** Reset the idle window (after connect, after every chunk). */
|
|
223
|
+
arm() {
|
|
224
|
+
if (timer)
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = setTimeout(() => controller.abort(IDLE_ABORT_REASON()), idleMs);
|
|
227
|
+
timer?.unref?.();
|
|
228
|
+
},
|
|
229
|
+
done() {
|
|
230
|
+
if (timer) {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
timer = null;
|
|
233
|
+
}
|
|
234
|
+
external?.removeEventListener('abort', onExternalAbort);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** Line-splitting over arbitrary network chunks (Ollama NDJSON). */
|
|
239
|
+
function createLineParser(onLine) {
|
|
240
|
+
let buf = '';
|
|
241
|
+
return {
|
|
242
|
+
push(chunk) {
|
|
243
|
+
buf += chunk;
|
|
244
|
+
let nl;
|
|
245
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
246
|
+
const line = buf.slice(0, nl).replace(/\r$/, '');
|
|
247
|
+
buf = buf.slice(nl + 1);
|
|
248
|
+
onLine(line);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
end() {
|
|
252
|
+
if (buf !== '') {
|
|
253
|
+
onLine(buf.replace(/\r$/, ''));
|
|
254
|
+
buf = '';
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function isLegacyFallbackEligible(err) {
|
|
260
|
+
// Only a clearly OpenAI-incompatible shape (no such route) justifies trying
|
|
261
|
+
// the Ollama-native endpoint; auth/4xx/5xx errors must surface as-is.
|
|
262
|
+
return /HTTP 40[45]\b/.test(String(err?.message || ''));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Build a chat completion provider over HTTP streaming (issue #199).
|
|
266
|
+
* `openai` → POST {base}/chat/completions with `stream:true`, SSE deltas.
|
|
267
|
+
* `ollama` → POST {base}/api/generate with `stream:true`, NDJSON deltas —
|
|
268
|
+
* the pre-#199 endpoint, upgraded from `stream:false` to streaming. The
|
|
269
|
+
* openai path additionally falls back to /api/generate once when the endpoint
|
|
270
|
+
* answers 404/405 (not OpenAI-shaped), so an Ollama root URL keeps working
|
|
271
|
+
* with default flags.
|
|
272
|
+
*/
|
|
273
|
+
export function resolveChatProvider(cfg) {
|
|
274
|
+
const fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
|
|
275
|
+
if (typeof fetchImpl !== 'function')
|
|
276
|
+
throw new Error('chat: no fetch implementation available');
|
|
277
|
+
const base = chatBaseOf(cfg);
|
|
278
|
+
const streamOllamaGenerate = async (system, user, onDelta, signal) => {
|
|
279
|
+
const guard = makeIdleGuard(signal);
|
|
280
|
+
let res;
|
|
281
|
+
try {
|
|
282
|
+
res = await fetchImpl(`${base}/api/generate`, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'content-type': 'application/json' },
|
|
285
|
+
signal: guard.signal,
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
model: cfg.model || 'llama3',
|
|
288
|
+
// legacy wire format: one prompt string, byte-identical to the
|
|
289
|
+
// pre-#199 single-string synthesis prompt
|
|
290
|
+
prompt: system + user,
|
|
291
|
+
stream: true,
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
guard.done();
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
guard.done();
|
|
301
|
+
const text = await res.text().catch(() => '');
|
|
302
|
+
throw new Error(`chat ${base}/api/generate -> HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
|
|
303
|
+
}
|
|
304
|
+
let full = '';
|
|
305
|
+
const parser = createLineParser((line) => {
|
|
306
|
+
const delta = ollamaDeltaText(line);
|
|
307
|
+
if (delta) {
|
|
308
|
+
full += delta;
|
|
309
|
+
onDelta?.(delta);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const decoder = new TextDecoder();
|
|
313
|
+
try {
|
|
314
|
+
for await (const chunk of res.body) {
|
|
315
|
+
parser.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
|
|
316
|
+
guard.arm();
|
|
317
|
+
}
|
|
318
|
+
parser.push(decoder.decode());
|
|
319
|
+
parser.end();
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
guard.done();
|
|
323
|
+
}
|
|
324
|
+
return full;
|
|
325
|
+
};
|
|
326
|
+
const streamOpenAiChat = async (system, user, onDelta, signal) => {
|
|
327
|
+
const headers = { 'content-type': 'application/json' };
|
|
328
|
+
const key = chatBearerKey(cfg);
|
|
329
|
+
if (key)
|
|
330
|
+
headers.authorization = `Bearer ${key}`;
|
|
331
|
+
const guard = makeIdleGuard(signal);
|
|
332
|
+
let res;
|
|
333
|
+
try {
|
|
334
|
+
res = await fetchImpl(`${base}/chat/completions`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers,
|
|
337
|
+
signal: guard.signal,
|
|
338
|
+
body: JSON.stringify({
|
|
339
|
+
...(cfg.model ? { model: cfg.model } : {}),
|
|
340
|
+
messages: [
|
|
341
|
+
{ role: 'system', content: system },
|
|
342
|
+
{ role: 'user', content: user },
|
|
343
|
+
],
|
|
344
|
+
stream: true,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
guard.done();
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
guard.done();
|
|
354
|
+
const text = await res.text().catch(() => '');
|
|
355
|
+
throw new Error(`chat ${base}/chat/completions -> HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
|
|
356
|
+
}
|
|
357
|
+
let full = '';
|
|
358
|
+
let wireDone = false;
|
|
359
|
+
const parser = createSseParser((payload) => {
|
|
360
|
+
if (payload === '[DONE]') {
|
|
361
|
+
wireDone = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const delta = openAiDeltaText(payload);
|
|
365
|
+
if (delta) {
|
|
366
|
+
full += delta;
|
|
367
|
+
onDelta?.(delta);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
const decoder = new TextDecoder();
|
|
371
|
+
try {
|
|
372
|
+
for await (const chunk of res.body) {
|
|
373
|
+
parser.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
|
|
374
|
+
guard.arm();
|
|
375
|
+
// leaving the for-await on [DONE] cancels the stream through the
|
|
376
|
+
// iterator's return() — a manual cancel() here would just reject
|
|
377
|
+
// with "ReadableStream is locked"
|
|
378
|
+
if (wireDone)
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
if (!wireDone) {
|
|
382
|
+
parser.push(decoder.decode());
|
|
383
|
+
parser.end();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
guard.done();
|
|
388
|
+
}
|
|
389
|
+
return full;
|
|
390
|
+
};
|
|
391
|
+
return {
|
|
392
|
+
id: cfg.provider,
|
|
393
|
+
model: cfg.model || '',
|
|
394
|
+
async complete(req) {
|
|
395
|
+
if (cfg.provider === 'ollama') {
|
|
396
|
+
return streamOllamaGenerate(req.system, req.user, req.onDelta, req.signal);
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
return await streamOpenAiChat(req.system, req.user, req.onDelta, req.signal);
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
if (isLegacyFallbackEligible(e)) {
|
|
403
|
+
return streamOllamaGenerate(req.system, req.user, req.onDelta, req.signal);
|
|
404
|
+
}
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
package/dist/quantization.d.ts
CHANGED
|
@@ -22,3 +22,15 @@ export declare function dequantizeFromInt8(vec: Int8Array): Float32Array;
|
|
|
22
22
|
* cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
|
|
23
23
|
*/
|
|
24
24
|
export declare function asymmetricCosineInt8(queryFp32: Float32Array, chunkInt8: Int8Array): number;
|
|
25
|
+
/**
|
|
26
|
+
* Precomputed int8 chunk norm = sqrt(sum(c_i^2)) (issue #190): constant per
|
|
27
|
+
* chunk, so caching it at load time removes half of the asymmetric sweep.
|
|
28
|
+
* Accumulates in the same order as `asymmetricCosineInt8` so the cached-norm
|
|
29
|
+
* fast path returns bitwise-equal scores.
|
|
30
|
+
*/
|
|
31
|
+
export declare function int8Norm(chunkInt8: Int8Array): number;
|
|
32
|
+
/**
|
|
33
|
+
* Dot-only sweep fast path (issue #190): identical to `asymmetricCosineInt8`
|
|
34
|
+
* when `chunkNorm` came from `int8Norm` on the same vector.
|
|
35
|
+
*/
|
|
36
|
+
export declare function asymmetricCosineInt8CachedNorm(queryFp32: Float32Array, chunkInt8: Int8Array, chunkNorm: number): number;
|
package/dist/quantization.js
CHANGED
|
@@ -83,3 +83,57 @@ export function asymmetricCosineInt8(queryFp32, chunkInt8) {
|
|
|
83
83
|
return 0.0;
|
|
84
84
|
return dot / Math.sqrt(normSq);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Precomputed int8 chunk norm = sqrt(sum(c_i^2)) (issue #190): constant per
|
|
88
|
+
* chunk, so caching it at load time removes half of the asymmetric sweep.
|
|
89
|
+
* Accumulates in the same order as `asymmetricCosineInt8` so the cached-norm
|
|
90
|
+
* fast path returns bitwise-equal scores.
|
|
91
|
+
*/
|
|
92
|
+
export function int8Norm(chunkInt8) {
|
|
93
|
+
const dim = chunkInt8.length;
|
|
94
|
+
let normSq = 0.0;
|
|
95
|
+
let i = 0;
|
|
96
|
+
const limit = dim - (dim % 8);
|
|
97
|
+
for (; i < limit; i += 8) {
|
|
98
|
+
normSq +=
|
|
99
|
+
chunkInt8[i] * chunkInt8[i] +
|
|
100
|
+
chunkInt8[i + 1] * chunkInt8[i + 1] +
|
|
101
|
+
chunkInt8[i + 2] * chunkInt8[i + 2] +
|
|
102
|
+
chunkInt8[i + 3] * chunkInt8[i + 3] +
|
|
103
|
+
chunkInt8[i + 4] * chunkInt8[i + 4] +
|
|
104
|
+
chunkInt8[i + 5] * chunkInt8[i + 5] +
|
|
105
|
+
chunkInt8[i + 6] * chunkInt8[i + 6] +
|
|
106
|
+
chunkInt8[i + 7] * chunkInt8[i + 7];
|
|
107
|
+
}
|
|
108
|
+
for (; i < dim; i++) {
|
|
109
|
+
normSq += chunkInt8[i] * chunkInt8[i];
|
|
110
|
+
}
|
|
111
|
+
return Math.sqrt(normSq);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Dot-only sweep fast path (issue #190): identical to `asymmetricCosineInt8`
|
|
115
|
+
* when `chunkNorm` came from `int8Norm` on the same vector.
|
|
116
|
+
*/
|
|
117
|
+
export function asymmetricCosineInt8CachedNorm(queryFp32, chunkInt8, chunkNorm) {
|
|
118
|
+
if (!(chunkNorm > 0))
|
|
119
|
+
return 0.0;
|
|
120
|
+
const dim = queryFp32.length;
|
|
121
|
+
let dot = 0.0;
|
|
122
|
+
let i = 0;
|
|
123
|
+
const limit = dim - (dim % 8);
|
|
124
|
+
for (; i < limit; i += 8) {
|
|
125
|
+
dot +=
|
|
126
|
+
queryFp32[i] * chunkInt8[i] +
|
|
127
|
+
queryFp32[i + 1] * chunkInt8[i + 1] +
|
|
128
|
+
queryFp32[i + 2] * chunkInt8[i + 2] +
|
|
129
|
+
queryFp32[i + 3] * chunkInt8[i + 3] +
|
|
130
|
+
queryFp32[i + 4] * chunkInt8[i + 4] +
|
|
131
|
+
queryFp32[i + 5] * chunkInt8[i + 5] +
|
|
132
|
+
queryFp32[i + 6] * chunkInt8[i + 6] +
|
|
133
|
+
queryFp32[i + 7] * chunkInt8[i + 7];
|
|
134
|
+
}
|
|
135
|
+
for (; i < dim; i++) {
|
|
136
|
+
dot += queryFp32[i] * chunkInt8[i];
|
|
137
|
+
}
|
|
138
|
+
return dot / chunkNorm;
|
|
139
|
+
}
|