@oortmem/local-models 0.0.0-stage → 0.4.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/LICENSE +202 -0
- package/README.md +27 -2
- package/dist/catalog.d.ts +80 -0
- package/dist/catalog.js +165 -0
- package/dist/embed.d.ts +32 -0
- package/dist/embed.js +78 -0
- package/dist/encode.d.ts +31 -0
- package/dist/encode.js +91 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +34 -0
- package/dist/files.d.ts +64 -0
- package/dist/files.js +167 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/rerank.d.ts +26 -0
- package/dist/rerank.js +48 -0
- package/dist/runtime.d.ts +49 -0
- package/dist/runtime.js +116 -0
- package/package.json +58 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oortmem/local-models
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Embeddings and reranking for Oort on this machine's CPU, through ONNX Runtime. No API keys, no account, and no network after the one-time download.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g oortmem @oortmem/local-models
|
|
7
|
+
oort models pull # downloads the default models (58 MB) into ~/.oort/models and switches to them
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
| Role | Default | Size | Licence | Upstream |
|
|
11
|
+
| ------ | ----------------------- | ----- | ---------- | ----------------------------------- |
|
|
12
|
+
| embed | `bge-small-en-v1.5` | 34 MB | MIT | BAAI/bge-small-en-v1.5 |
|
|
13
|
+
| rerank | `ms-marco-minilm-l6-v2` | 23 MB | Apache-2.0 | cross-encoder/ms-marco-MiniLM-L6-v2 |
|
|
14
|
+
|
|
15
|
+
Larger multilingual models (`qwen3-embedding-0.6b`, `bge-reranker-v2-m3`, about 600 MB each) are available with `oort models pull <id>`; they take about a second to load, so they suit `oort mcp` better than per-prompt hooks.
|
|
16
|
+
|
|
17
|
+
Every file is pinned to a Hugging Face commit and its SHA-256, checked before it is kept. Models are never downloaded during a hook or a recall, only by `oort models pull` or `oort setup`.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { localEmbedder, localReranker } from '@oortmem/local-models';
|
|
21
|
+
|
|
22
|
+
const embed = localEmbedder({ home: '/path/to/.oort', dimension: 512 });
|
|
23
|
+
const rerank = localReranker({ home: '/path/to/.oort' });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Runtime dependencies: [onnxruntime-node](https://github.com/microsoft/onnxruntime) (MIT) and [@huggingface/tokenizers](https://github.com/huggingface/tokenizers.js) (Apache-2.0).
|
|
27
|
+
|
|
28
|
+
Part of [Oort](https://github.com/oortmem/oort), open-source memory for AI agents. See the [main README](https://github.com/oortmem/oort#readme) for how the pieces fit. Apache-2.0.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models Oort can run locally. Each file is pinned to a Hugging Face commit and a SHA-256,
|
|
3
|
+
* so a pull is reproducible and a tampered or truncated download is refused.
|
|
4
|
+
*
|
|
5
|
+
* The defaults are small on purpose: every Claude Code hook is a new process, so a model is
|
|
6
|
+
* loaded from disk once per prompt. Measured on an Apple M1 Max (single thread, CPU time):
|
|
7
|
+
* bge-small loads in about 110 ms (22 ms from the optimised cache) and embeds a query in 6 ms;
|
|
8
|
+
* the MiniLM reranker loads in about 60 ms (13 ms cached) and scores 16 short documents in
|
|
9
|
+
* 110 ms. The large models load in 0.8 to 1 s, need over 1 GB of RAM, and suit the long-lived
|
|
10
|
+
* `oort mcp` server rather than per-prompt hooks.
|
|
11
|
+
*/
|
|
12
|
+
export type ModelRole = 'embed' | 'rerank';
|
|
13
|
+
/** One file of a model: where it comes from in the repo, and what it must hash to. */
|
|
14
|
+
export interface ModelFile {
|
|
15
|
+
/** The local file name inside the model's directory. */
|
|
16
|
+
name: 'model.onnx' | 'tokenizer.json' | 'tokenizer_config.json';
|
|
17
|
+
/** Path inside the Hugging Face repository. */
|
|
18
|
+
path: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}
|
|
22
|
+
interface BaseSpec {
|
|
23
|
+
/** The id `embed.model` / `rerank.model` names, and the directory under `~/.oort/models`. */
|
|
24
|
+
id: string;
|
|
25
|
+
role: ModelRole;
|
|
26
|
+
/** One line for `oort models`. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** `small` loads fast enough for hooks; `large` is for the long-lived MCP server. */
|
|
29
|
+
size: 'small' | 'large';
|
|
30
|
+
/** The ONNX conversion that is downloaded, pinned to a commit. */
|
|
31
|
+
source: {
|
|
32
|
+
repo: string;
|
|
33
|
+
revision: string;
|
|
34
|
+
};
|
|
35
|
+
/** The original model the conversion was made from. */
|
|
36
|
+
upstream: string;
|
|
37
|
+
/** SPDX id of the model weights' licence (all allow commercial use). */
|
|
38
|
+
licence: string;
|
|
39
|
+
files: readonly ModelFile[];
|
|
40
|
+
/** Longest input in tokens, special tokens included. */
|
|
41
|
+
maxTokens: number;
|
|
42
|
+
}
|
|
43
|
+
export interface EmbedModelSpec extends BaseSpec {
|
|
44
|
+
role: 'embed';
|
|
45
|
+
/** The width of the model's own embedding. */
|
|
46
|
+
nativeDimension: number;
|
|
47
|
+
/** `cls`: the first token; `mean`: masked mean; `last`: the last real token. */
|
|
48
|
+
pooling: 'cls' | 'mean' | 'last';
|
|
49
|
+
/** Trained with Matryoshka loss, so a prefix of the embedding is itself an embedding. */
|
|
50
|
+
matryoshka: boolean;
|
|
51
|
+
queryPrefix: string;
|
|
52
|
+
documentPrefix: string;
|
|
53
|
+
/** A decoder export that takes (empty) past key/value inputs. */
|
|
54
|
+
kvCache?: {
|
|
55
|
+
layers: number;
|
|
56
|
+
heads: number;
|
|
57
|
+
headDim: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export interface RerankModelSpec extends BaseSpec {
|
|
61
|
+
role: 'rerank';
|
|
62
|
+
/**
|
|
63
|
+
* The score is `sigmoid(scale * logit + shift)`. MS MARCO cross-encoders emit uncalibrated
|
|
64
|
+
* logits (unrelated pairs sit near -10); this maps them onto the 0 to 1 range that
|
|
65
|
+
* `recall.minRerankScore` (0.35) was set for. Provisional until calibrated on the eval.
|
|
66
|
+
*/
|
|
67
|
+
calibration: {
|
|
68
|
+
scale: number;
|
|
69
|
+
shift: number;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export type ModelSpec = EmbedModelSpec | RerankModelSpec;
|
|
73
|
+
export declare const MODELS: readonly ModelSpec[];
|
|
74
|
+
export declare const DEFAULT_EMBED_MODEL = "bge-small-en-v1.5";
|
|
75
|
+
export declare const DEFAULT_RERANK_MODEL = "ms-marco-minilm-l6-v2";
|
|
76
|
+
/** The catalog entry for `id`, or undefined. */
|
|
77
|
+
export declare function findModel(id: string): ModelSpec | undefined;
|
|
78
|
+
/** Total download size of `spec` in bytes. */
|
|
79
|
+
export declare function downloadBytes(spec: ModelSpec): number;
|
|
80
|
+
export {};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models Oort can run locally. Each file is pinned to a Hugging Face commit and a SHA-256,
|
|
3
|
+
* so a pull is reproducible and a tampered or truncated download is refused.
|
|
4
|
+
*
|
|
5
|
+
* The defaults are small on purpose: every Claude Code hook is a new process, so a model is
|
|
6
|
+
* loaded from disk once per prompt. Measured on an Apple M1 Max (single thread, CPU time):
|
|
7
|
+
* bge-small loads in about 110 ms (22 ms from the optimised cache) and embeds a query in 6 ms;
|
|
8
|
+
* the MiniLM reranker loads in about 60 ms (13 ms cached) and scores 16 short documents in
|
|
9
|
+
* 110 ms. The large models load in 0.8 to 1 s, need over 1 GB of RAM, and suit the long-lived
|
|
10
|
+
* `oort mcp` server rather than per-prompt hooks.
|
|
11
|
+
*/
|
|
12
|
+
const HF_TOKENIZER_JSON_BERT_UNCASED = {
|
|
13
|
+
name: 'tokenizer.json',
|
|
14
|
+
path: 'tokenizer.json',
|
|
15
|
+
bytes: 711_396,
|
|
16
|
+
sha256: 'd241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66',
|
|
17
|
+
};
|
|
18
|
+
export const MODELS = [
|
|
19
|
+
{
|
|
20
|
+
id: 'bge-small-en-v1.5',
|
|
21
|
+
role: 'embed',
|
|
22
|
+
label: 'BGE small, English (int8, 34 MB)',
|
|
23
|
+
size: 'small',
|
|
24
|
+
source: {
|
|
25
|
+
repo: 'Xenova/bge-small-en-v1.5',
|
|
26
|
+
revision: 'ea104dacec62c0de699686887e3f920caeb4f3e3',
|
|
27
|
+
},
|
|
28
|
+
upstream: 'BAAI/bge-small-en-v1.5',
|
|
29
|
+
licence: 'MIT',
|
|
30
|
+
files: [
|
|
31
|
+
{
|
|
32
|
+
name: 'model.onnx',
|
|
33
|
+
path: 'onnx/model_int8.onnx',
|
|
34
|
+
bytes: 33_760_831,
|
|
35
|
+
sha256: 'bf64d05457cb391fa88d045faf5927a15ea36d96228ddf23ea970087afdc1197',
|
|
36
|
+
},
|
|
37
|
+
HF_TOKENIZER_JSON_BERT_UNCASED,
|
|
38
|
+
{
|
|
39
|
+
name: 'tokenizer_config.json',
|
|
40
|
+
path: 'tokenizer_config.json',
|
|
41
|
+
bytes: 366,
|
|
42
|
+
sha256: '9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
maxTokens: 512,
|
|
46
|
+
nativeDimension: 384,
|
|
47
|
+
pooling: 'cls',
|
|
48
|
+
matryoshka: false,
|
|
49
|
+
queryPrefix: 'Represent this sentence for searching relevant passages: ',
|
|
50
|
+
documentPrefix: '',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'ms-marco-minilm-l6-v2',
|
|
54
|
+
role: 'rerank',
|
|
55
|
+
label: 'MS MARCO MiniLM-L6 cross-encoder, English (int8, 23 MB)',
|
|
56
|
+
size: 'small',
|
|
57
|
+
source: {
|
|
58
|
+
repo: 'Xenova/ms-marco-MiniLM-L-6-v2',
|
|
59
|
+
revision: 'a09144355adeed5f58c8ed011d209bf8ee5a1fec',
|
|
60
|
+
},
|
|
61
|
+
upstream: 'cross-encoder/ms-marco-MiniLM-L6-v2',
|
|
62
|
+
licence: 'Apache-2.0',
|
|
63
|
+
files: [
|
|
64
|
+
{
|
|
65
|
+
name: 'model.onnx',
|
|
66
|
+
path: 'onnx/model_int8.onnx',
|
|
67
|
+
bytes: 23_012_404,
|
|
68
|
+
sha256: 'a13ec391ca99f49886694e12d3e800521f36d4267d7d448c34421c541a2baf50',
|
|
69
|
+
},
|
|
70
|
+
HF_TOKENIZER_JSON_BERT_UNCASED,
|
|
71
|
+
{
|
|
72
|
+
name: 'tokenizer_config.json',
|
|
73
|
+
path: 'tokenizer_config.json',
|
|
74
|
+
bytes: 1_242,
|
|
75
|
+
sha256: '0b29c7bfc889e53b36d9dd3e686dd4300f6525110eaa98c76a5dafceb2029f53',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
maxTokens: 512,
|
|
79
|
+
calibration: { scale: 0.5, shift: 2.4 },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'qwen3-embedding-0.6b',
|
|
83
|
+
role: 'embed',
|
|
84
|
+
label: 'Qwen3 Embedding 0.6B, multilingual (int8, 614 MB; slow to load, for oort mcp)',
|
|
85
|
+
size: 'large',
|
|
86
|
+
source: {
|
|
87
|
+
repo: 'onnx-community/Qwen3-Embedding-0.6B-ONNX',
|
|
88
|
+
revision: 'c25a394dd583836952667c12f008335071b3f43d',
|
|
89
|
+
},
|
|
90
|
+
upstream: 'Qwen/Qwen3-Embedding-0.6B',
|
|
91
|
+
licence: 'Apache-2.0',
|
|
92
|
+
files: [
|
|
93
|
+
{
|
|
94
|
+
name: 'model.onnx',
|
|
95
|
+
path: 'onnx/model_int8.onnx',
|
|
96
|
+
bytes: 613_527_539,
|
|
97
|
+
sha256: '6d0ea863f78b4a84afa3c7fcba1ec341572b5e28121aef77b7092b1dfdf679c7',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'tokenizer.json',
|
|
101
|
+
path: 'tokenizer.json',
|
|
102
|
+
bytes: 11_423_705,
|
|
103
|
+
sha256: 'def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'tokenizer_config.json',
|
|
107
|
+
path: 'tokenizer_config.json',
|
|
108
|
+
bytes: 9_731,
|
|
109
|
+
sha256: '977648852447cb6587327ff3205b0a84cf2fc9f05621d6c8e88a497caafab2e1',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
maxTokens: 1024,
|
|
113
|
+
nativeDimension: 1024,
|
|
114
|
+
pooling: 'last',
|
|
115
|
+
matryoshka: true,
|
|
116
|
+
queryPrefix: 'Instruct: Given a question about a software project, retrieve the note that answers it\nQuery:',
|
|
117
|
+
documentPrefix: '',
|
|
118
|
+
kvCache: { layers: 28, heads: 8, headDim: 128 },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'bge-reranker-v2-m3',
|
|
122
|
+
role: 'rerank',
|
|
123
|
+
label: 'BGE reranker v2 m3, multilingual (int8, 571 MB; slow, for oort mcp)',
|
|
124
|
+
size: 'large',
|
|
125
|
+
source: {
|
|
126
|
+
repo: 'onnx-community/bge-reranker-v2-m3-ONNX',
|
|
127
|
+
revision: '6f5ff65298512715a1e669753bc754d2bc8f367b',
|
|
128
|
+
},
|
|
129
|
+
upstream: 'BAAI/bge-reranker-v2-m3',
|
|
130
|
+
licence: 'Apache-2.0',
|
|
131
|
+
files: [
|
|
132
|
+
{
|
|
133
|
+
name: 'model.onnx',
|
|
134
|
+
path: 'onnx/model_int8.onnx',
|
|
135
|
+
bytes: 570_727_094,
|
|
136
|
+
sha256: '912fc1215c2dbff6499700534bd8d31253af01573861abbfc43afd1fab6cce5d',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'tokenizer.json',
|
|
140
|
+
path: 'tokenizer.json',
|
|
141
|
+
bytes: 17_082_900,
|
|
142
|
+
sha256: '8bf8afbfd11306bd872018c53bfdf2e160a56f8edbcf49933324404791c148d3',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'tokenizer_config.json',
|
|
146
|
+
path: 'tokenizer_config.json',
|
|
147
|
+
bytes: 1_203,
|
|
148
|
+
sha256: 'b87c8703482b0300d3da30e201519aa641f6a450f5eb5bf1e624afbf70c74d80',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
maxTokens: 512,
|
|
152
|
+
calibration: { scale: 1, shift: 0 },
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
export const DEFAULT_EMBED_MODEL = 'bge-small-en-v1.5';
|
|
156
|
+
export const DEFAULT_RERANK_MODEL = 'ms-marco-minilm-l6-v2';
|
|
157
|
+
/** The catalog entry for `id`, or undefined. */
|
|
158
|
+
export function findModel(id) {
|
|
159
|
+
return MODELS.find((m) => m.id === id);
|
|
160
|
+
}
|
|
161
|
+
/** Total download size of `spec` in bytes. */
|
|
162
|
+
export function downloadBytes(spec) {
|
|
163
|
+
return spec.files.reduce((n, f) => n + f.bytes, 0);
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=catalog.js.map
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Embedder } from '@oortmem/core';
|
|
2
|
+
import { type EmbedModelSpec } from './catalog.js';
|
|
3
|
+
import { type Runtime } from './runtime.js';
|
|
4
|
+
export interface LocalEmbedderOptions {
|
|
5
|
+
/** `$OORT_HOME`; models live in `<home>/models/<id>`. */
|
|
6
|
+
home: string;
|
|
7
|
+
/** Catalog id, default `bge-small-en-v1.5`. */
|
|
8
|
+
model?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The store's dimension. A narrower model is zero-padded to it (cosine similarity is
|
|
11
|
+
* unchanged); a wider Matryoshka model is truncated to it; a wider plain model is refused.
|
|
12
|
+
*/
|
|
13
|
+
dimension: number;
|
|
14
|
+
/** Default: ONNX Runtime. */
|
|
15
|
+
runtime?: Runtime;
|
|
16
|
+
threads?: number;
|
|
17
|
+
/** Texts per forward pass (default 16). */
|
|
18
|
+
batchSize?: number;
|
|
19
|
+
/** A model outside the catalog (tests, experiments); used when its id is `model`. */
|
|
20
|
+
spec?: EmbedModelSpec;
|
|
21
|
+
}
|
|
22
|
+
export interface LocalEmbedder extends Embedder {
|
|
23
|
+
readonly model: string;
|
|
24
|
+
/** Loads the model now instead of on the first `embed` (a warm process can preload). */
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Embeddings on this machine's CPU. Nothing is loaded until the first `embed` (or `load`):
|
|
29
|
+
* constructing one only checks that the model was pulled, so a hook whose lexical search
|
|
30
|
+
* answers on its own pays nothing. Vectors are unit length, then int8 with a per-vector scale.
|
|
31
|
+
*/
|
|
32
|
+
export declare function localEmbedder(opts: LocalEmbedderOptions): LocalEmbedder;
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEFAULT_EMBED_MODEL } from './catalog.js';
|
|
2
|
+
import { clipChars, clipTokens, normalize, quantize, toBatch } from './encode.js';
|
|
3
|
+
import { LocalModelError, readyModel } from './errors.js';
|
|
4
|
+
import { lazyLoad, onnxRuntime } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* Embeddings on this machine's CPU. Nothing is loaded until the first `embed` (or `load`):
|
|
7
|
+
* constructing one only checks that the model was pulled, so a hook whose lexical search
|
|
8
|
+
* answers on its own pays nothing. Vectors are unit length, then int8 with a per-vector scale.
|
|
9
|
+
*/
|
|
10
|
+
export function localEmbedder(opts) {
|
|
11
|
+
const id = opts.model ?? DEFAULT_EMBED_MODEL;
|
|
12
|
+
const { spec: s, dir } = readyModel(opts.home, 'embed', id, opts.spec);
|
|
13
|
+
const spec = s;
|
|
14
|
+
const { dimension } = opts;
|
|
15
|
+
if (spec.nativeDimension > dimension && !spec.matryoshka) {
|
|
16
|
+
throw new LocalModelError('dimension', `local model ${id} has ${spec.nativeDimension} dimensions and cannot be truncated to the store's ${dimension}.`);
|
|
17
|
+
}
|
|
18
|
+
const keep = Math.min(spec.nativeDimension, dimension);
|
|
19
|
+
const runtime = opts.runtime ?? onnxRuntime();
|
|
20
|
+
const batchSize = opts.batchSize ?? 16;
|
|
21
|
+
const load = lazyLoad(runtime, dir, opts.threads);
|
|
22
|
+
async function forward(m, texts) {
|
|
23
|
+
const encodings = texts.map((t) => clipTokens(m.tokenizer.encode(clipChars(t, spec.maxTokens)), spec.maxTokens));
|
|
24
|
+
const batch = toBatch(encodings, m.session.inputNames, spec.kvCache);
|
|
25
|
+
const out = (await m.session.run(batch.feeds, ['last_hidden_state']))['last_hidden_state'];
|
|
26
|
+
if (out === undefined || out.dims.length !== 3) {
|
|
27
|
+
throw new Error(`local model ${id}: unexpected output shape`);
|
|
28
|
+
}
|
|
29
|
+
const hidden = out.dims[2];
|
|
30
|
+
if (hidden !== spec.nativeDimension) {
|
|
31
|
+
throw new Error(`local model ${id}: output has ${hidden} dimensions, expected ${spec.nativeDimension}`);
|
|
32
|
+
}
|
|
33
|
+
const L = batch.length;
|
|
34
|
+
const d = out.data;
|
|
35
|
+
return batch.lengths.map((len, i) => {
|
|
36
|
+
const v = new Float32Array(hidden);
|
|
37
|
+
const row = (j) => (i * L + j) * hidden;
|
|
38
|
+
if (spec.pooling === 'cls')
|
|
39
|
+
v.set(d.subarray(row(0), row(0) + hidden));
|
|
40
|
+
else if (spec.pooling === 'last')
|
|
41
|
+
v.set(d.subarray(row(len - 1), row(len - 1) + hidden));
|
|
42
|
+
else {
|
|
43
|
+
for (let j = 0; j < len; j++) {
|
|
44
|
+
const at = row(j);
|
|
45
|
+
for (let h = 0; h < hidden; h++)
|
|
46
|
+
v[h] = v[h] + d[at + h];
|
|
47
|
+
}
|
|
48
|
+
for (let h = 0; h < hidden; h++)
|
|
49
|
+
v[h] = v[h] / len;
|
|
50
|
+
}
|
|
51
|
+
// Matryoshka: normalise the prefix; a narrow model is padded with zeros after it.
|
|
52
|
+
const prefix = normalize(v.slice(0, keep));
|
|
53
|
+
const full = new Float32Array(dimension);
|
|
54
|
+
full.set(prefix);
|
|
55
|
+
return quantize(full);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
dimension,
|
|
60
|
+
model: id,
|
|
61
|
+
async load() {
|
|
62
|
+
await load();
|
|
63
|
+
},
|
|
64
|
+
async embed(texts, kind) {
|
|
65
|
+
if (texts.length === 0)
|
|
66
|
+
return [];
|
|
67
|
+
const m = await load();
|
|
68
|
+
const prefix = kind === 'query' ? spec.queryPrefix : spec.documentPrefix;
|
|
69
|
+
const out = [];
|
|
70
|
+
for (let i = 0; i < texts.length; i += batchSize) {
|
|
71
|
+
const chunk = texts.slice(i, i + batchSize).map((t) => prefix + t);
|
|
72
|
+
out.push(...(await forward(m, chunk)));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=embed.js.map
|
package/dist/encode.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Encoding, Feed } from './runtime.js';
|
|
2
|
+
/** Roughly four characters per token: text past this many characters cannot fit anyway. */
|
|
3
|
+
export declare function clipChars(text: string, maxTokens: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* At most `maxTokens` tokens. The tail is cut but the final special token (`[SEP]`, `</s>`,
|
|
6
|
+
* `<|endoftext|>`) is kept, so a cross-encoder pair loses the end of its document, never the
|
|
7
|
+
* structure, and last-token pooling still reads the end-of-text token.
|
|
8
|
+
*/
|
|
9
|
+
export declare function clipTokens(e: Encoding, maxTokens: number): Encoding;
|
|
10
|
+
export interface Batch {
|
|
11
|
+
feeds: Record<string, Feed>;
|
|
12
|
+
/** Padded sequence length. */
|
|
13
|
+
length: number;
|
|
14
|
+
/** Real (unpadded) length of each row. */
|
|
15
|
+
lengths: number[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Right-padded int64 inputs for the names the session declares: `input_ids`, `attention_mask`,
|
|
19
|
+
* and when declared `token_type_ids` and `position_ids`; a decoder export also gets empty past
|
|
20
|
+
* key/value tensors.
|
|
21
|
+
*/
|
|
22
|
+
export declare function toBatch(encodings: Encoding[], inputNames: readonly string[], kvCache?: {
|
|
23
|
+
layers: number;
|
|
24
|
+
heads: number;
|
|
25
|
+
headDim: number;
|
|
26
|
+
}): Batch;
|
|
27
|
+
/** Float vector to int8 with a per-vector scale (cosine similarity is scale-free). */
|
|
28
|
+
export declare function quantize(v: Float32Array): Int8Array;
|
|
29
|
+
/** In place: unit length (a zero vector stays zero). */
|
|
30
|
+
export declare function normalize(v: Float32Array): Float32Array;
|
|
31
|
+
export declare const sigmoid: (x: number) => number;
|
package/dist/encode.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Roughly four characters per token: text past this many characters cannot fit anyway. */
|
|
2
|
+
export function clipChars(text, maxTokens) {
|
|
3
|
+
const max = maxTokens * 6;
|
|
4
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* At most `maxTokens` tokens. The tail is cut but the final special token (`[SEP]`, `</s>`,
|
|
8
|
+
* `<|endoftext|>`) is kept, so a cross-encoder pair loses the end of its document, never the
|
|
9
|
+
* structure, and last-token pooling still reads the end-of-text token.
|
|
10
|
+
*/
|
|
11
|
+
export function clipTokens(e, maxTokens) {
|
|
12
|
+
if (e.ids.length <= maxTokens)
|
|
13
|
+
return e;
|
|
14
|
+
const last = e.ids.length - 1;
|
|
15
|
+
const out = { ids: [...e.ids.slice(0, maxTokens - 1), e.ids[last]] };
|
|
16
|
+
if (e.token_type_ids !== undefined) {
|
|
17
|
+
const tt = e.token_type_ids;
|
|
18
|
+
out.token_type_ids = [...tt.slice(0, maxTokens - 1), tt[last] ?? 0];
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Right-padded int64 inputs for the names the session declares: `input_ids`, `attention_mask`,
|
|
24
|
+
* and when declared `token_type_ids` and `position_ids`; a decoder export also gets empty past
|
|
25
|
+
* key/value tensors.
|
|
26
|
+
*/
|
|
27
|
+
export function toBatch(encodings, inputNames, kvCache) {
|
|
28
|
+
const n = encodings.length;
|
|
29
|
+
const length = Math.max(1, ...encodings.map((e) => e.ids.length));
|
|
30
|
+
const ids = new BigInt64Array(n * length);
|
|
31
|
+
const mask = new BigInt64Array(n * length);
|
|
32
|
+
const types = new BigInt64Array(n * length);
|
|
33
|
+
const positions = new BigInt64Array(n * length);
|
|
34
|
+
encodings.forEach((e, i) => {
|
|
35
|
+
for (let j = 0; j < e.ids.length; j++) {
|
|
36
|
+
const at = i * length + j;
|
|
37
|
+
ids[at] = BigInt(e.ids[j]);
|
|
38
|
+
mask[at] = 1n;
|
|
39
|
+
types[at] = BigInt(e.token_type_ids?.[j] ?? 0);
|
|
40
|
+
positions[at] = BigInt(j);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const dims = [n, length];
|
|
44
|
+
const feeds = {
|
|
45
|
+
input_ids: { type: 'int64', data: ids, dims },
|
|
46
|
+
attention_mask: { type: 'int64', data: mask, dims },
|
|
47
|
+
};
|
|
48
|
+
if (inputNames.includes('token_type_ids'))
|
|
49
|
+
feeds['token_type_ids'] = { type: 'int64', data: types, dims };
|
|
50
|
+
if (inputNames.includes('position_ids'))
|
|
51
|
+
feeds['position_ids'] = { type: 'int64', data: positions, dims };
|
|
52
|
+
if (kvCache !== undefined) {
|
|
53
|
+
const empty = [n, kvCache.heads, 0, kvCache.headDim];
|
|
54
|
+
for (let l = 0; l < kvCache.layers; l++) {
|
|
55
|
+
for (const kv of ['key', 'value']) {
|
|
56
|
+
feeds[`past_key_values.${l}.${kv}`] = {
|
|
57
|
+
type: 'float32',
|
|
58
|
+
data: new Float32Array(0),
|
|
59
|
+
dims: empty,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { feeds, length, lengths: encodings.map((e) => e.ids.length) };
|
|
65
|
+
}
|
|
66
|
+
/** Float vector to int8 with a per-vector scale (cosine similarity is scale-free). */
|
|
67
|
+
export function quantize(v) {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const x of v)
|
|
70
|
+
max = Math.max(max, Math.abs(x));
|
|
71
|
+
const out = new Int8Array(v.length);
|
|
72
|
+
if (max === 0)
|
|
73
|
+
return out;
|
|
74
|
+
const s = 127 / max;
|
|
75
|
+
for (let i = 0; i < v.length; i++)
|
|
76
|
+
out[i] = Math.round(v[i] * s);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** In place: unit length (a zero vector stays zero). */
|
|
80
|
+
export function normalize(v) {
|
|
81
|
+
let s = 0;
|
|
82
|
+
for (const x of v)
|
|
83
|
+
s += x * x;
|
|
84
|
+
const n = Math.sqrt(s);
|
|
85
|
+
if (n > 0)
|
|
86
|
+
for (let i = 0; i < v.length; i++)
|
|
87
|
+
v[i] = v[i] / n;
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
export const sigmoid = (x) => 1 / (1 + Math.exp(-x));
|
|
91
|
+
//# sourceMappingURL=encode.js.map
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ModelRole, type ModelSpec } from './catalog.js';
|
|
2
|
+
export type LocalModelErrorKind = 'unknown-model' | 'wrong-role' | 'not-ready' | 'dimension';
|
|
3
|
+
/** A local model that cannot be used as configured; the message names the fix. */
|
|
4
|
+
export declare class LocalModelError extends Error {
|
|
5
|
+
readonly kind: LocalModelErrorKind;
|
|
6
|
+
constructor(kind: LocalModelErrorKind, message: string);
|
|
7
|
+
}
|
|
8
|
+
/** The spec for `id` in `role`, pulled and complete, or a `LocalModelError` saying how to fix it. */
|
|
9
|
+
export declare function readyModel(home: string, role: ModelRole, id: string, custom?: ModelSpec): {
|
|
10
|
+
spec: ModelSpec;
|
|
11
|
+
dir: string;
|
|
12
|
+
};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { findModel, MODELS } from './catalog.js';
|
|
2
|
+
import { modelDir, modelState } from './files.js';
|
|
3
|
+
/** A local model that cannot be used as configured; the message names the fix. */
|
|
4
|
+
export class LocalModelError extends Error {
|
|
5
|
+
kind;
|
|
6
|
+
constructor(kind, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
this.name = 'LocalModelError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const STATE_FIX = {
|
|
13
|
+
missing: 'is not downloaded',
|
|
14
|
+
incomplete: 'is incompletely downloaded',
|
|
15
|
+
outdated: 'was downloaded for another version of Oort',
|
|
16
|
+
};
|
|
17
|
+
/** The spec for `id` in `role`, pulled and complete, or a `LocalModelError` saying how to fix it. */
|
|
18
|
+
export function readyModel(home, role, id, custom) {
|
|
19
|
+
const spec = custom?.id === id ? custom : findModel(id);
|
|
20
|
+
const key = role === 'embed' ? 'embed.model' : 'rerank.model';
|
|
21
|
+
if (spec === undefined) {
|
|
22
|
+
const ids = MODELS.filter((m) => m.role === role).map((m) => m.id);
|
|
23
|
+
throw new LocalModelError('unknown-model', `${key} "${id}" is not a local model; use one of ${ids.join(', ')} (oort models).`);
|
|
24
|
+
}
|
|
25
|
+
if (spec.role !== role) {
|
|
26
|
+
throw new LocalModelError('wrong-role', `${key} "${id}" is a ${spec.role} model.`);
|
|
27
|
+
}
|
|
28
|
+
const state = modelState(home, spec);
|
|
29
|
+
if (state !== 'ready') {
|
|
30
|
+
throw new LocalModelError('not-ready', `local model ${id} ${STATE_FIX[state]}; run \`oort models pull ${id}\`.`);
|
|
31
|
+
}
|
|
32
|
+
return { spec, dir: modelDir(home, id) };
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=errors.js.map
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ModelFile, ModelSpec } from './catalog.js';
|
|
2
|
+
/** Written last by a successful pull: the model is complete and its checksums matched. */
|
|
3
|
+
export declare const MANIFEST = "oort-model.json";
|
|
4
|
+
/** Where model files are fetched from; `OORT_MODELS_URL` points `oort models pull` at a mirror. */
|
|
5
|
+
export declare const HF_BASE_URL = "https://huggingface.co";
|
|
6
|
+
export interface Manifest {
|
|
7
|
+
id: string;
|
|
8
|
+
revision: string;
|
|
9
|
+
files: {
|
|
10
|
+
name: string;
|
|
11
|
+
bytes: number;
|
|
12
|
+
sha256: string;
|
|
13
|
+
}[];
|
|
14
|
+
pulledAt: string;
|
|
15
|
+
}
|
|
16
|
+
/** `$OORT_HOME/models`. */
|
|
17
|
+
export declare function modelsRoot(home: string): string;
|
|
18
|
+
export declare function modelDir(home: string, id: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* - `ready`: pulled, the manifest matches the catalog, every file has its size.
|
|
21
|
+
* - `missing`: never pulled (or removed).
|
|
22
|
+
* - `incomplete`: a pull did not finish, or a file is gone or has the wrong size.
|
|
23
|
+
* - `outdated`: pulled for another revision or checksum than this version of Oort pins.
|
|
24
|
+
*/
|
|
25
|
+
export type ModelState = 'ready' | 'missing' | 'incomplete' | 'outdated';
|
|
26
|
+
/**
|
|
27
|
+
* Cheap check (a manifest read and a stat per file, no hashing), safe on every hook. A full
|
|
28
|
+
* re-hash is `verifyModel`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function modelState(home: string, spec: ModelSpec): ModelState;
|
|
31
|
+
/** Re-hashes every file of a pulled model; lists each file that does not match its pin. */
|
|
32
|
+
export declare function verifyModel(home: string, spec: ModelSpec): Promise<{
|
|
33
|
+
ok: boolean;
|
|
34
|
+
bad: string[];
|
|
35
|
+
}>;
|
|
36
|
+
/** A download that does not hash to its pin (or has the wrong size): nothing is kept. */
|
|
37
|
+
export declare class ChecksumError extends Error {
|
|
38
|
+
readonly model: string;
|
|
39
|
+
readonly file: string;
|
|
40
|
+
constructor(model: string, file: string, detail: string);
|
|
41
|
+
}
|
|
42
|
+
export interface PullOptions {
|
|
43
|
+
/** Default: the global fetch. Only `pullModel` touches the network. */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
/** Default `HF_BASE_URL`. */
|
|
46
|
+
baseUrl?: string;
|
|
47
|
+
/** Called as bytes arrive, per file. */
|
|
48
|
+
onProgress?: (p: {
|
|
49
|
+
file: string;
|
|
50
|
+
received: number;
|
|
51
|
+
total: number;
|
|
52
|
+
}) => void;
|
|
53
|
+
}
|
|
54
|
+
export declare function fileUrl(spec: ModelSpec, f: ModelFile, baseUrl?: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Downloads every file of `spec` into `$OORT_HOME/models/<id>` (owner-only), verifying each
|
|
57
|
+
* against its pinned size and SHA-256 before it is moved into place, then writes the manifest.
|
|
58
|
+
* A file already present with the right hash is kept, so an interrupted pull resumes. On any
|
|
59
|
+
* failure the manifest is absent, so the model stays unusable rather than half-trusted.
|
|
60
|
+
* Returns the number of bytes downloaded.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pullModel(home: string, spec: ModelSpec, opts?: PullOptions): Promise<number>;
|
|
63
|
+
/** Deletes a model's directory (files, manifest, optimised cache). True when it existed. */
|
|
64
|
+
export declare function removeModel(home: string, id: string): boolean;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { ensurePrivateDir, writePrivateFile } from '@oortmem/core';
|
|
5
|
+
/** Written last by a successful pull: the model is complete and its checksums matched. */
|
|
6
|
+
export const MANIFEST = 'oort-model.json';
|
|
7
|
+
/** Where model files are fetched from; `OORT_MODELS_URL` points `oort models pull` at a mirror. */
|
|
8
|
+
export const HF_BASE_URL = 'https://huggingface.co';
|
|
9
|
+
/** `$OORT_HOME/models`. */
|
|
10
|
+
export function modelsRoot(home) {
|
|
11
|
+
return path.join(home, 'models');
|
|
12
|
+
}
|
|
13
|
+
export function modelDir(home, id) {
|
|
14
|
+
return path.join(modelsRoot(home), id);
|
|
15
|
+
}
|
|
16
|
+
function readManifest(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Cheap check (a manifest read and a stat per file, no hashing), safe on every hook. A full
|
|
26
|
+
* re-hash is `verifyModel`.
|
|
27
|
+
*/
|
|
28
|
+
export function modelState(home, spec) {
|
|
29
|
+
const dir = modelDir(home, spec.id);
|
|
30
|
+
if (!fs.existsSync(dir))
|
|
31
|
+
return 'missing';
|
|
32
|
+
const manifest = readManifest(dir);
|
|
33
|
+
if (manifest === undefined)
|
|
34
|
+
return 'incomplete';
|
|
35
|
+
const pinned = spec.files.every((f) => manifest.files.some((m) => m.name === f.name && m.sha256 === f.sha256 && m.bytes === f.bytes));
|
|
36
|
+
if (manifest.revision !== spec.source.revision || !pinned)
|
|
37
|
+
return 'outdated';
|
|
38
|
+
for (const f of spec.files) {
|
|
39
|
+
let size;
|
|
40
|
+
try {
|
|
41
|
+
size = fs.statSync(path.join(dir, f.name)).size;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return 'incomplete';
|
|
45
|
+
}
|
|
46
|
+
if (size !== f.bytes)
|
|
47
|
+
return 'incomplete';
|
|
48
|
+
}
|
|
49
|
+
return 'ready';
|
|
50
|
+
}
|
|
51
|
+
async function sha256Of(file) {
|
|
52
|
+
const hash = createHash('sha256');
|
|
53
|
+
for await (const chunk of fs.createReadStream(file))
|
|
54
|
+
hash.update(chunk);
|
|
55
|
+
return hash.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
/** Re-hashes every file of a pulled model; lists each file that does not match its pin. */
|
|
58
|
+
export async function verifyModel(home, spec) {
|
|
59
|
+
const dir = modelDir(home, spec.id);
|
|
60
|
+
const bad = [];
|
|
61
|
+
for (const f of spec.files) {
|
|
62
|
+
const file = path.join(dir, f.name);
|
|
63
|
+
if (!fs.existsSync(file) || (await sha256Of(file)) !== f.sha256)
|
|
64
|
+
bad.push(f.name);
|
|
65
|
+
}
|
|
66
|
+
return { ok: bad.length === 0, bad };
|
|
67
|
+
}
|
|
68
|
+
/** A download that does not hash to its pin (or has the wrong size): nothing is kept. */
|
|
69
|
+
export class ChecksumError extends Error {
|
|
70
|
+
model;
|
|
71
|
+
file;
|
|
72
|
+
constructor(model, file, detail) {
|
|
73
|
+
super(`${model}: ${file} failed verification (${detail}); nothing was kept`);
|
|
74
|
+
this.model = model;
|
|
75
|
+
this.file = file;
|
|
76
|
+
this.name = 'ChecksumError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function fileUrl(spec, f, baseUrl = HF_BASE_URL) {
|
|
80
|
+
const root = baseUrl.replace(/\/+$/, '');
|
|
81
|
+
return `${root}/${spec.source.repo}/resolve/${spec.source.revision}/${f.path}`;
|
|
82
|
+
}
|
|
83
|
+
async function download(spec, f, dest, opts) {
|
|
84
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
85
|
+
const res = await doFetch(fileUrl(spec, f, opts.baseUrl), { redirect: 'follow' });
|
|
86
|
+
if (!res.ok || res.body === null) {
|
|
87
|
+
void res.body?.cancel().catch(() => undefined);
|
|
88
|
+
throw new Error(`${spec.id}: ${f.name} download failed (HTTP ${res.status})`);
|
|
89
|
+
}
|
|
90
|
+
const part = `${dest}.${process.pid}-${randomBytes(4).toString('hex')}.part`;
|
|
91
|
+
const hash = createHash('sha256');
|
|
92
|
+
let received = 0;
|
|
93
|
+
const fd = fs.openSync(part, 'wx', 0o600);
|
|
94
|
+
try {
|
|
95
|
+
try {
|
|
96
|
+
for await (const chunk of res.body) {
|
|
97
|
+
received += chunk.byteLength;
|
|
98
|
+
if (received > f.bytes) {
|
|
99
|
+
throw new ChecksumError(spec.id, f.name, `more than the expected ${f.bytes} bytes`);
|
|
100
|
+
}
|
|
101
|
+
hash.update(chunk);
|
|
102
|
+
fs.writeSync(fd, chunk);
|
|
103
|
+
opts.onProgress?.({ file: f.name, received, total: f.bytes });
|
|
104
|
+
}
|
|
105
|
+
fs.fsyncSync(fd);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
fs.closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
if (received !== f.bytes) {
|
|
111
|
+
throw new ChecksumError(spec.id, f.name, `${received} bytes, expected ${f.bytes}`);
|
|
112
|
+
}
|
|
113
|
+
const digest = hash.digest('hex');
|
|
114
|
+
if (digest !== f.sha256)
|
|
115
|
+
throw new ChecksumError(spec.id, f.name, 'SHA-256 mismatch');
|
|
116
|
+
fs.renameSync(part, dest);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
fs.rmSync(part, { force: true });
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Downloads every file of `spec` into `$OORT_HOME/models/<id>` (owner-only), verifying each
|
|
125
|
+
* against its pinned size and SHA-256 before it is moved into place, then writes the manifest.
|
|
126
|
+
* A file already present with the right hash is kept, so an interrupted pull resumes. On any
|
|
127
|
+
* failure the manifest is absent, so the model stays unusable rather than half-trusted.
|
|
128
|
+
* Returns the number of bytes downloaded.
|
|
129
|
+
*/
|
|
130
|
+
export async function pullModel(home, spec, opts = {}) {
|
|
131
|
+
const dir = modelDir(home, spec.id);
|
|
132
|
+
ensurePrivateDir(modelsRoot(home));
|
|
133
|
+
ensurePrivateDir(dir);
|
|
134
|
+
fs.rmSync(path.join(dir, MANIFEST), { force: true });
|
|
135
|
+
let downloaded = 0;
|
|
136
|
+
for (const f of spec.files) {
|
|
137
|
+
const dest = path.join(dir, f.name);
|
|
138
|
+
if (fs.existsSync(dest) &&
|
|
139
|
+
fs.statSync(dest).size === f.bytes &&
|
|
140
|
+
(await sha256Of(dest)) === f.sha256) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
fs.rmSync(dest, { force: true });
|
|
144
|
+
await download(spec, f, dest, opts);
|
|
145
|
+
downloaded += f.bytes;
|
|
146
|
+
}
|
|
147
|
+
const manifest = {
|
|
148
|
+
id: spec.id,
|
|
149
|
+
revision: spec.source.revision,
|
|
150
|
+
files: spec.files.map((f) => ({ name: f.name, bytes: f.bytes, sha256: f.sha256 })),
|
|
151
|
+
pulledAt: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
writePrivateFile(path.join(dir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
154
|
+
return downloaded;
|
|
155
|
+
}
|
|
156
|
+
/** Deletes a model's directory (files, manifest, optimised cache). True when it existed. */
|
|
157
|
+
export function removeModel(home, id) {
|
|
158
|
+
if (id === '' || path.basename(id) !== id || id.startsWith('.')) {
|
|
159
|
+
throw new Error(`not a model id: ${JSON.stringify(id)}`);
|
|
160
|
+
}
|
|
161
|
+
const dir = modelDir(home, id);
|
|
162
|
+
if (!fs.existsSync(dir))
|
|
163
|
+
return false;
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=files.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, MODELS, downloadBytes, findModel, type EmbedModelSpec, type ModelFile, type ModelRole, type ModelSpec, type RerankModelSpec, } from './catalog.js';
|
|
2
|
+
export { ChecksumError, HF_BASE_URL, MANIFEST, fileUrl, modelDir, modelState, modelsRoot, pullModel, removeModel, verifyModel, type ModelState, type PullOptions, } from './files.js';
|
|
3
|
+
export { LocalModelError, readyModel, type LocalModelErrorKind } from './errors.js';
|
|
4
|
+
export { localEmbedder, type LocalEmbedder, type LocalEmbedderOptions } from './embed.js';
|
|
5
|
+
export { localReranker, type LocalReranker, type LocalRerankerOptions } from './rerank.js';
|
|
6
|
+
export { defaultThreads, onnxRuntime, type Encoding, type Feed, type LoadedModel, type Output, type Runtime, type Session, type TextTokenizer, } from './runtime.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, MODELS, downloadBytes, findModel, } from './catalog.js';
|
|
2
|
+
export { ChecksumError, HF_BASE_URL, MANIFEST, fileUrl, modelDir, modelState, modelsRoot, pullModel, removeModel, verifyModel, } from './files.js';
|
|
3
|
+
export { LocalModelError, readyModel } from './errors.js';
|
|
4
|
+
export { localEmbedder } from './embed.js';
|
|
5
|
+
export { localReranker } from './rerank.js';
|
|
6
|
+
export { defaultThreads, onnxRuntime, } from './runtime.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
package/dist/rerank.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Reranker } from '@oortmem/core';
|
|
2
|
+
import { type RerankModelSpec } from './catalog.js';
|
|
3
|
+
import { type Runtime } from './runtime.js';
|
|
4
|
+
export interface LocalRerankerOptions {
|
|
5
|
+
/** `$OORT_HOME`; models live in `<home>/models/<id>`. */
|
|
6
|
+
home: string;
|
|
7
|
+
/** Catalog id, default `ms-marco-minilm-l6-v2`. */
|
|
8
|
+
model?: string;
|
|
9
|
+
/** Default: ONNX Runtime. */
|
|
10
|
+
runtime?: Runtime;
|
|
11
|
+
threads?: number;
|
|
12
|
+
/** Pairs per forward pass (default 16). */
|
|
13
|
+
batchSize?: number;
|
|
14
|
+
/** A model outside the catalog (tests, experiments); used when its id is `model`. */
|
|
15
|
+
spec?: RerankModelSpec;
|
|
16
|
+
}
|
|
17
|
+
export interface LocalReranker extends Reranker {
|
|
18
|
+
readonly model: string;
|
|
19
|
+
/** Loads the model now instead of on the first `rerank`. */
|
|
20
|
+
load(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A cross-encoder on this machine's CPU: each (query, document) pair is scored jointly, and the
|
|
24
|
+
* logit is mapped to 0..1 by the model's calibration. Loaded on the first `rerank`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function localReranker(opts: LocalRerankerOptions): LocalReranker;
|
package/dist/rerank.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { DEFAULT_RERANK_MODEL } from './catalog.js';
|
|
2
|
+
import { clipChars, clipTokens, sigmoid, toBatch } from './encode.js';
|
|
3
|
+
import { readyModel } from './errors.js';
|
|
4
|
+
import { lazyLoad, onnxRuntime } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* A cross-encoder on this machine's CPU: each (query, document) pair is scored jointly, and the
|
|
7
|
+
* logit is mapped to 0..1 by the model's calibration. Loaded on the first `rerank`.
|
|
8
|
+
*/
|
|
9
|
+
export function localReranker(opts) {
|
|
10
|
+
const id = opts.model ?? DEFAULT_RERANK_MODEL;
|
|
11
|
+
const { spec: s, dir } = readyModel(opts.home, 'rerank', id, opts.spec);
|
|
12
|
+
const spec = s;
|
|
13
|
+
const load = lazyLoad(opts.runtime ?? onnxRuntime(), dir, opts.threads);
|
|
14
|
+
const batchSize = opts.batchSize ?? 16;
|
|
15
|
+
const { scale, shift } = spec.calibration;
|
|
16
|
+
return {
|
|
17
|
+
model: id,
|
|
18
|
+
async load() {
|
|
19
|
+
await load();
|
|
20
|
+
},
|
|
21
|
+
async rerank(query, docs, topK) {
|
|
22
|
+
if (docs.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
const m = await load();
|
|
25
|
+
const q = clipChars(query, spec.maxTokens / 4);
|
|
26
|
+
const scores = [];
|
|
27
|
+
for (let i = 0; i < docs.length; i += batchSize) {
|
|
28
|
+
const encodings = docs
|
|
29
|
+
.slice(i, i + batchSize)
|
|
30
|
+
.map((d) => clipTokens(m.tokenizer.encode(q, clipChars(d.text, spec.maxTokens)), spec.maxTokens));
|
|
31
|
+
const batch = toBatch(encodings, m.session.inputNames);
|
|
32
|
+
const out = (await m.session.run(batch.feeds, ['logits']))['logits'];
|
|
33
|
+
if (out === undefined || out.dims[0] !== encodings.length) {
|
|
34
|
+
throw new Error(`local model ${id}: unexpected output shape`);
|
|
35
|
+
}
|
|
36
|
+
const per = out.dims[1] ?? 1;
|
|
37
|
+
for (let j = 0; j < encodings.length; j++) {
|
|
38
|
+
scores.push(sigmoid(scale * out.data[j * per] + shift));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return docs
|
|
42
|
+
.map((d, i) => ({ id: d.id, score: scores[i] }))
|
|
43
|
+
.sort((a, b) => b.score - a.score)
|
|
44
|
+
.slice(0, Math.max(0, topK));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=rerank.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between Oort's pooling and scoring code and the inference runtime. The default is
|
|
3
|
+
* ONNX Runtime (onnxruntime-node) with Hugging Face's pure-JS tokenizers, both imported on
|
|
4
|
+
* first use only; tests inject a fake.
|
|
5
|
+
*/
|
|
6
|
+
export interface Feed {
|
|
7
|
+
type: 'int64' | 'float32';
|
|
8
|
+
data: BigInt64Array | Float32Array;
|
|
9
|
+
dims: number[];
|
|
10
|
+
}
|
|
11
|
+
export interface Output {
|
|
12
|
+
data: Float32Array;
|
|
13
|
+
dims: readonly number[];
|
|
14
|
+
}
|
|
15
|
+
export interface Session {
|
|
16
|
+
readonly inputNames: readonly string[];
|
|
17
|
+
run(feeds: Record<string, Feed>, outputs: readonly string[]): Promise<Record<string, Output>>;
|
|
18
|
+
}
|
|
19
|
+
export interface Encoding {
|
|
20
|
+
ids: number[];
|
|
21
|
+
token_type_ids?: number[];
|
|
22
|
+
}
|
|
23
|
+
export interface TextTokenizer {
|
|
24
|
+
/** Special tokens included; `pair` is the second segment of a cross-encoder input. */
|
|
25
|
+
encode(text: string, pair?: string): Encoding;
|
|
26
|
+
}
|
|
27
|
+
export interface LoadedModel {
|
|
28
|
+
session: Session;
|
|
29
|
+
tokenizer: TextTokenizer;
|
|
30
|
+
}
|
|
31
|
+
export interface Runtime {
|
|
32
|
+
/** Loads the model in `dir` (`model.onnx` plus tokenizer files), preferring the optimised cache. */
|
|
33
|
+
load(dir: string, opts?: {
|
|
34
|
+
threads?: number;
|
|
35
|
+
}): Promise<LoadedModel>;
|
|
36
|
+
/**
|
|
37
|
+
* Writes the optimised cache beside `model.onnx` (graph optimisations already applied, so a
|
|
38
|
+
* later load skips them: about 5x faster for the default models). Run once after a pull.
|
|
39
|
+
*/
|
|
40
|
+
optimize?(dir: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/** Intra-op threads: up to 4. Small batches gain little beyond that, and hooks share the CPU. */
|
|
43
|
+
export declare function defaultThreads(): number;
|
|
44
|
+
export declare function onnxRuntime(): Runtime;
|
|
45
|
+
/**
|
|
46
|
+
* Loads `dir` on the first call and shares the result; a failed load is retried on the next
|
|
47
|
+
* call rather than remembered.
|
|
48
|
+
*/
|
|
49
|
+
export declare function lazyLoad(runtime: Runtime, dir: string, threads: number | undefined): () => Promise<LoadedModel>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
/** Intra-op threads: up to 4. Small batches gain little beyond that, and hooks share the CPU. */
|
|
6
|
+
export function defaultThreads() {
|
|
7
|
+
return Math.max(1, Math.min(4, os.availableParallelism()));
|
|
8
|
+
}
|
|
9
|
+
/** The cache file name carries the runtime version: a runtime upgrade never loads a stale one. */
|
|
10
|
+
function cacheFile(dir, ort) {
|
|
11
|
+
const v = ort.env.versions.node ?? ort.env.versions.common;
|
|
12
|
+
return path.join(dir, `model.ort-${v}.opt.onnx`);
|
|
13
|
+
}
|
|
14
|
+
async function loadTokenizer(dir) {
|
|
15
|
+
const { Tokenizer } = await import('@huggingface/tokenizers');
|
|
16
|
+
const read = (name) => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
17
|
+
const t = new Tokenizer(read('tokenizer.json'), read('tokenizer_config.json'));
|
|
18
|
+
return {
|
|
19
|
+
encode(text, pair) {
|
|
20
|
+
const e = t.encode(text, { text_pair: pair ?? null, return_token_type_ids: true });
|
|
21
|
+
return { ids: e.ids, token_type_ids: e.token_type_ids };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function wrap(ort, s) {
|
|
26
|
+
return {
|
|
27
|
+
inputNames: s.inputNames,
|
|
28
|
+
async run(feeds, outputs) {
|
|
29
|
+
const tensors = {};
|
|
30
|
+
for (const [name, f] of Object.entries(feeds)) {
|
|
31
|
+
tensors[name] = new ort.Tensor(f.type, f.data, f.dims);
|
|
32
|
+
}
|
|
33
|
+
const out = await s.run(tensors, outputs);
|
|
34
|
+
const result = {};
|
|
35
|
+
for (const name of outputs) {
|
|
36
|
+
const t = out[name];
|
|
37
|
+
if (t === undefined || !(t.data instanceof Float32Array)) {
|
|
38
|
+
throw new Error(`local model: output ${name} is missing or not float32`);
|
|
39
|
+
}
|
|
40
|
+
result[name] = { data: t.data, dims: t.dims };
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function onnxRuntime() {
|
|
47
|
+
let ortP;
|
|
48
|
+
const ort = () => (ortP ??= import('onnxruntime-node'));
|
|
49
|
+
// Warnings (unused initialisers and the like) would land on a hook's stderr.
|
|
50
|
+
const quiet = { logSeverityLevel: 3 };
|
|
51
|
+
return {
|
|
52
|
+
async load(dir, opts = {}) {
|
|
53
|
+
const o = await ort();
|
|
54
|
+
const tokenizer = loadTokenizer(dir);
|
|
55
|
+
const threads = { intraOpNumThreads: opts.threads ?? defaultThreads(), interOpNumThreads: 1 };
|
|
56
|
+
const cached = cacheFile(dir, o);
|
|
57
|
+
let session;
|
|
58
|
+
if (fs.existsSync(cached)) {
|
|
59
|
+
try {
|
|
60
|
+
session = await o.InferenceSession.create(cached, {
|
|
61
|
+
...quiet,
|
|
62
|
+
...threads,
|
|
63
|
+
graphOptimizationLevel: 'disabled',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
session = undefined; // a damaged cache: the original model still loads
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
session ??= await o.InferenceSession.create(path.join(dir, 'model.onnx'), {
|
|
71
|
+
...quiet,
|
|
72
|
+
...threads,
|
|
73
|
+
graphOptimizationLevel: 'all',
|
|
74
|
+
});
|
|
75
|
+
return { session: wrap(o, session), tokenizer: await tokenizer };
|
|
76
|
+
},
|
|
77
|
+
async optimize(dir) {
|
|
78
|
+
const o = await ort();
|
|
79
|
+
const dest = cacheFile(dir, o);
|
|
80
|
+
const tmp = `${dest}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
// `extended`, not `all`: the `all` level adds CPU-specific layout transforms.
|
|
83
|
+
const s = await o.InferenceSession.create(path.join(dir, 'model.onnx'), {
|
|
84
|
+
...quiet,
|
|
85
|
+
graphOptimizationLevel: 'extended',
|
|
86
|
+
optimizedModelFilePath: tmp,
|
|
87
|
+
});
|
|
88
|
+
await s.release();
|
|
89
|
+
fs.chmodSync(tmp, 0o600);
|
|
90
|
+
fs.renameSync(tmp, dest);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
fs.rmSync(tmp, { force: true });
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Loads `dir` on the first call and shares the result; a failed load is retried on the next
|
|
100
|
+
* call rather than remembered.
|
|
101
|
+
*/
|
|
102
|
+
export function lazyLoad(runtime, dir, threads) {
|
|
103
|
+
let loaded;
|
|
104
|
+
return () => {
|
|
105
|
+
if (loaded === undefined) {
|
|
106
|
+
const p = runtime.load(dir, threads === undefined ? {} : { threads });
|
|
107
|
+
p.catch(() => {
|
|
108
|
+
if (loaded === p)
|
|
109
|
+
loaded = undefined;
|
|
110
|
+
});
|
|
111
|
+
loaded = p;
|
|
112
|
+
}
|
|
113
|
+
return loaded;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=runtime.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oortmem/local-models",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Oort embeddings and reranking on this machine's CPU (ONNX Runtime): no API keys, no account.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"oort",
|
|
7
|
+
"agent-memory",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"llm-memory",
|
|
10
|
+
"embeddings",
|
|
11
|
+
"reranker",
|
|
12
|
+
"onnx",
|
|
13
|
+
"offline"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://oortcloud.dev",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/oortmem/oort/issues",
|
|
18
|
+
"email": "hello@oortcloud.dev"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/oortmem/oort.git",
|
|
23
|
+
"directory": "packages/local-models"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"author": "Oort <hello@oortcloud.dev>",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.15.0"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"!dist/**/*.map",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@huggingface/tokenizers": "0.2.0",
|
|
51
|
+
"@oortmem/core": "0.4.0",
|
|
52
|
+
"onnxruntime-node": "1.31.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"vitest": "^5.0.5"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc -p tsconfig.json",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
60
|
+
}
|
|
6
61
|
}
|