@armadra/agent 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/CHANGELOG.zh-CN.md +32 -0
- package/dist/agent/session-classifier.d.ts +2 -0
- package/dist/agent/session-classifier.js +24 -3
- package/dist/agent/session-extensions.d.ts +1 -0
- package/dist/agent/session-extensions.js +3 -0
- package/dist/agent/session-subagent.js +9 -2
- package/dist/agent/subagent-background.d.ts +5 -1
- package/dist/agent/subagent-background.js +13 -1
- package/dist/agent/subagent-fork.d.ts +10 -5
- package/dist/agent/subagent-fork.js +22 -6
- package/dist/agent/subagent-registry.d.ts +1 -1
- package/dist/agent/subagent-registry.js +1 -1
- package/dist/agents/catalog.d.ts +1 -0
- package/dist/ai/apis/anthropic-request.js +6 -1
- package/dist/ai/apis/cache-params.js +2 -1
- package/dist/ai/apis/google-request.js +2 -1
- package/dist/ai/apis/max-tokens.d.ts +4 -0
- package/dist/ai/apis/max-tokens.js +12 -1
- package/dist/ai/apis/openai-request.js +3 -1
- package/dist/ai/apis/openai-responses-request.js +2 -1
- package/dist/ai/json-body.d.ts +33 -1
- package/dist/ai/json-body.js +96 -18
- package/dist/ai/providers/enrich.d.ts +6 -2
- package/dist/ai/providers/enrich.js +13 -2
- package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
- package/dist/ai/providers/max-tokens-cache.js +95 -0
- package/dist/ai/providers/registry.js +3 -3
- package/dist/bundle/ama.cjs +982 -442
- package/dist/checkpoints/shadow-git.d.ts +5 -1
- package/dist/checkpoints/shadow-git.js +11 -2
- package/dist/cli/bootstrap.js +13 -5
- package/dist/cli/compose-providers.d.ts +1 -0
- package/dist/cli/compose-providers.js +3 -0
- package/dist/cli/compose-store.d.ts +6 -2
- package/dist/cli/compose-store.js +8 -4
- package/dist/cli/compose.js +2 -2
- package/dist/cli/deps.d.ts +1 -1
- package/dist/config/json-schema.js +1 -0
- package/dist/config/key-docs.js +1 -0
- package/dist/config/schema-w5.js +2 -0
- package/dist/config/settings-registry.js +1 -0
- package/dist/config/types-w5.d.ts +5 -0
- package/dist/i18n/catalog.d.ts +2 -0
- package/dist/i18n/messages/config-keys.d.ts +2 -0
- package/dist/i18n/messages/config-keys.js +2 -0
- package/dist/i18n/messages/config.d.ts +2 -0
- package/dist/i18n/messages/interactive.d.ts +2 -0
- package/dist/i18n/messages/interactive.js +2 -0
- package/dist/i18n/messages/session.js +2 -2
- package/dist/modes/acp/acp-server.js +17 -15
- package/dist/modes/acp/acp-sessions.d.ts +14 -1
- package/dist/modes/acp/acp-sessions.js +44 -1
- package/dist/modes/interactive/agent-ui.js +7 -0
- package/dist/session/manager.d.ts +18 -0
- package/dist/session/manager.js +75 -14
- package/dist/session/offload.d.ts +59 -0
- package/dist/session/offload.js +218 -0
- package/dist/session/store.d.ts +12 -4
- package/dist/session/store.js +18 -8
- package/dist/tools/background-jobs.d.ts +4 -4
- package/dist/tools/background-jobs.js +4 -4
- package/dist/tools/bash.d.ts +2 -1
- package/dist/tools/bash.js +4 -3
- package/dist/tools/read-lines.d.ts +39 -0
- package/dist/tools/read-lines.js +163 -86
- package/dist/tools/read.js +3 -3
- package/dist/tools/task-ctl.js +2 -1
- package/dist/tools/task.d.ts +9 -0
- package/dist/tools/task.js +27 -1
- package/docs/acp.md +6 -3
- package/docs/agents.md +14 -3
- package/docs/en/acp.md +2 -2
- package/docs/en/permissions.md +1 -1
- package/docs/en/providers.md +4 -4
- package/docs/en/sessions.md +2 -1
- package/docs/permissions.md +1 -1
- package/docs/providers.md +9 -5
- package/docs/session-format.md +11 -0
- package/docs/sessions.md +2 -1
- package/package.json +1 -1
package/dist/ai/json-body.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* 键、`[`)与已验证无需转义的大字符串(需要转义的大字符串整个交给原生);`toJSON`、`undefined` /
|
|
12
12
|
* 函数 / symbol 的跳过与 `null`、数字格式、转义全部由原生处理(带 `toJSON` 的值经单键容器序列化,
|
|
13
13
|
* 保证 `toJSON(key)` 收到与原生相同的键)。
|
|
14
|
+
*
|
|
15
|
+
* 图片由转换器包成 {@link LargeString}(`prefix + text`):片段路径直接写转录里的原字符串,不经模板串
|
|
16
|
+
* 拼接后的扁平化;无需转义的结论与字节数按 ImageBlock 缓存,同一张图每进程只扫一次。流式发送时大片段
|
|
17
|
+
* 按 {@link STREAM_CHUNK_CHARS} 切块编码。
|
|
14
18
|
*/
|
|
15
19
|
/** 达到这个长度(UTF-16 码元)的字符串才走片段路径。 */
|
|
16
20
|
export const LARGE_STRING_BYTES = 64 * 1024;
|
|
@@ -23,6 +27,7 @@ const CHECK_CHUNK = 16 * 1024;
|
|
|
23
27
|
* 每个大字符串每次序列化只查这一次。
|
|
24
28
|
*/
|
|
25
29
|
function isPlain(value) {
|
|
30
|
+
plainScans.count++;
|
|
26
31
|
for (let i = 0; i < value.length; i += CHECK_CHUNK) {
|
|
27
32
|
const chunk = value.slice(i, i + CHECK_CHUNK);
|
|
28
33
|
if (JSON.stringify(chunk).length !== chunk.length + 2)
|
|
@@ -30,6 +35,54 @@ function isPlain(value) {
|
|
|
30
35
|
}
|
|
31
36
|
return true;
|
|
32
37
|
}
|
|
38
|
+
/** 短字符串无需转义(`LargeString.prefix` 用)。 */
|
|
39
|
+
const isShortPlain = (value) => JSON.stringify(value).length === value.length + 2;
|
|
40
|
+
/** `isPlain` 的调用次数(测试用:同一块图片第二次序列化不应再扫)。 */
|
|
41
|
+
export const plainScans = { count: 0 };
|
|
42
|
+
/**
|
|
43
|
+
* 请求体里的大字符串叶子 `prefix + text`(data URL 或裸 base64),用来代替模板串拼接:拼接得到的
|
|
44
|
+
* cons string 在每次序列化时都会被扁平化成一份新的整图字符串。片段路径直接写 `prefix` 与 `text`
|
|
45
|
+
* (`text` 是转录里的原字符串,不拷贝);`text` 无需转义的结论按 `key`(转录里身份稳定的 ImageBlock)
|
|
46
|
+
* 缓存,同一块每进程只扫一次。约定:同一 `key` 的 `text` 不变。其它遍历者看到的是一个带 `toJSON`
|
|
47
|
+
* 的对象,原生 `JSON.stringify` 得到相同字符串。由 {@link largeString} 构造。
|
|
48
|
+
*/
|
|
49
|
+
export class LargeString {
|
|
50
|
+
prefix;
|
|
51
|
+
key;
|
|
52
|
+
text;
|
|
53
|
+
constructor(prefix, key, text) {
|
|
54
|
+
this.prefix = prefix;
|
|
55
|
+
this.key = key;
|
|
56
|
+
this.text = text;
|
|
57
|
+
}
|
|
58
|
+
toJSON() {
|
|
59
|
+
return this.prefix + this.text;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `prefix + text` 作为请求体的值:`text` 够大且 `prefix` 自身无需转义时给 {@link LargeString},
|
|
64
|
+
* 否则就是拼好的字符串(小图与以前完全相同)。
|
|
65
|
+
*/
|
|
66
|
+
export function largeString(prefix, key, text) {
|
|
67
|
+
if (text.length < LARGE_STRING_BYTES || !isShortPlain(prefix))
|
|
68
|
+
return prefix + text;
|
|
69
|
+
return new LargeString(prefix, key, text);
|
|
70
|
+
}
|
|
71
|
+
/** 已确认无需转义的 `LargeString.text`:键是 `key`,值是当时的 UTF-8 字节数(同时省掉再数一遍)。 */
|
|
72
|
+
const plainBytes = new WeakMap();
|
|
73
|
+
/** `LargeString.text` 可原样写入时返回其 UTF-8 字节数,否则 undefined。 */
|
|
74
|
+
function plainLargeBytes(value) {
|
|
75
|
+
if (!isShortPlain(value.prefix))
|
|
76
|
+
return undefined;
|
|
77
|
+
const known = plainBytes.get(value.key);
|
|
78
|
+
if (known !== undefined && known.length === value.text.length)
|
|
79
|
+
return known.bytes;
|
|
80
|
+
if (!isPlain(value.text))
|
|
81
|
+
return undefined;
|
|
82
|
+
const bytes = Buffer.byteLength(value.text, "utf8");
|
|
83
|
+
plainBytes.set(value.key, { length: value.text.length, bytes });
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
33
86
|
/** 结构遍历的深度上限:更深(或有环)时整棵子树交给原生,环由原生照常抛 TypeError。 */
|
|
34
87
|
const MAX_DEPTH = 64;
|
|
35
88
|
const isLargeString = (value) => typeof value === "string" && value.length >= LARGE_STRING_BYTES;
|
|
@@ -48,6 +101,8 @@ function isContainer(value) {
|
|
|
48
101
|
function collectLarge(value, marked, depth) {
|
|
49
102
|
if (isLargeString(value))
|
|
50
103
|
return true;
|
|
104
|
+
if (value instanceof LargeString)
|
|
105
|
+
return value.text.length >= LARGE_STRING_BYTES;
|
|
51
106
|
if (depth >= MAX_DEPTH || !isContainer(value))
|
|
52
107
|
return false;
|
|
53
108
|
let found = false;
|
|
@@ -81,18 +136,24 @@ function nativeValue(key, value) {
|
|
|
81
136
|
/** 片段:已拼好的 JSON 文本与大字符串交替;连续的小文本先在字符串里累积。 */
|
|
82
137
|
class Pieces {
|
|
83
138
|
parts = [];
|
|
139
|
+
/** 已推入 `parts` 的 UTF-8 字节数。 */
|
|
140
|
+
bytes = 0;
|
|
84
141
|
pending = "";
|
|
85
142
|
text(chunk) {
|
|
86
143
|
this.pending += chunk;
|
|
87
144
|
}
|
|
88
|
-
/**
|
|
89
|
-
large(value) {
|
|
90
|
-
|
|
145
|
+
/** 大字符串原样写入(引号与 `head` 进前一个小片段);`bytes` 已知时不再数一遍。 */
|
|
146
|
+
large(value, head = "", bytes = Buffer.byteLength(value, "utf8")) {
|
|
147
|
+
const before = `${this.pending}"${head}`;
|
|
148
|
+
this.parts.push(before, value);
|
|
149
|
+
this.bytes += Buffer.byteLength(before, "utf8") + bytes;
|
|
91
150
|
this.pending = '"';
|
|
92
151
|
}
|
|
93
152
|
finish() {
|
|
94
|
-
if (this.pending.length > 0)
|
|
153
|
+
if (this.pending.length > 0) {
|
|
95
154
|
this.parts.push(this.pending);
|
|
155
|
+
this.bytes += Buffer.byteLength(this.pending, "utf8");
|
|
156
|
+
}
|
|
96
157
|
this.pending = "";
|
|
97
158
|
return this.parts;
|
|
98
159
|
}
|
|
@@ -109,6 +170,15 @@ function writeEntry(key, value, marked, out, prefix, depth) {
|
|
|
109
170
|
}
|
|
110
171
|
return true;
|
|
111
172
|
}
|
|
173
|
+
if (value instanceof LargeString && value.text.length >= LARGE_STRING_BYTES) {
|
|
174
|
+
const bytes = plainLargeBytes(value);
|
|
175
|
+
out.text(prefix);
|
|
176
|
+
if (bytes !== undefined)
|
|
177
|
+
out.large(value.text, value.prefix, bytes);
|
|
178
|
+
else
|
|
179
|
+
out.text(JSON.stringify(value.toJSON())); // 需要转义:交给原生
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
112
182
|
// 深度与 collectLarge 一致;有环时越过上限交给原生,由它抛 TypeError
|
|
113
183
|
if (depth < MAX_DEPTH && isContainer(value) && marked.has(value)) {
|
|
114
184
|
out.text(prefix);
|
|
@@ -150,18 +220,9 @@ function jsonParts(body) {
|
|
|
150
220
|
if (!collectLarge(body, marked, 0))
|
|
151
221
|
return undefined;
|
|
152
222
|
const out = new Pieces();
|
|
153
|
-
|
|
154
|
-
if (!isPlain(body))
|
|
155
|
-
return undefined;
|
|
156
|
-
out.large(body);
|
|
157
|
-
}
|
|
158
|
-
else
|
|
159
|
-
writeContainer(body, marked, out, 0);
|
|
223
|
+
writeEntry("", body, marked, out, "", 0); // 顶层:键为 "",与原生调用 toJSON 的键相同
|
|
160
224
|
const parts = out.finish();
|
|
161
|
-
|
|
162
|
-
for (const part of parts)
|
|
163
|
-
length += Buffer.byteLength(part, "utf8");
|
|
164
|
-
return { parts, length };
|
|
225
|
+
return { parts, length: out.bytes };
|
|
165
226
|
}
|
|
166
227
|
/** 与 `Buffer.from(JSON.stringify(body), "utf8")` 逐字节相同;大字符串不经中间字符串。 */
|
|
167
228
|
export function serializeJsonBody(body) {
|
|
@@ -178,11 +239,16 @@ export function serializeJsonBody(body) {
|
|
|
178
239
|
}
|
|
179
240
|
return buf;
|
|
180
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* 流式请求体每块最多编码的码元数:大片段(一整张图)按游标切片(V8 切片串不拷贝)逐块编码,
|
|
244
|
+
* 发送期间只多出一块的 UTF-8 字节,而不是一整张图。
|
|
245
|
+
*/
|
|
246
|
+
export const STREAM_CHUNK_CHARS = 256 * 1024;
|
|
181
247
|
/**
|
|
182
248
|
* 给 fetch 的请求体。不含大字符串时就是 `JSON.stringify(body)`(与以前完全相同);含大字符串时是
|
|
183
249
|
* 逐片段编码的流,并给出总字节数。不交给 fetch 一个 Buffer:Node 的 fetch 会把 BufferSource
|
|
184
250
|
* 请求体再复制两份(实测 mock 300 步带图峰值反而从约 1.0 GB 升到 1.85 GB),流则按片段边读边发,
|
|
185
|
-
*
|
|
251
|
+
* 同一时刻只多出一块(≤ {@link STREAM_CHUNK_CHARS} 码元)的 UTF-8 字节。字节序列与 `serializeJsonBody` 相同。
|
|
186
252
|
*/
|
|
187
253
|
export function jsonFetchBody(body) {
|
|
188
254
|
const split = jsonParts(body);
|
|
@@ -190,6 +256,7 @@ export function jsonFetchBody(body) {
|
|
|
190
256
|
return { body: JSON.stringify(body) };
|
|
191
257
|
const { parts } = split;
|
|
192
258
|
let next = 0;
|
|
259
|
+
let offset = 0; // 当前片段已发出的码元数
|
|
193
260
|
const stream = new ReadableStream({
|
|
194
261
|
pull(controller) {
|
|
195
262
|
const part = parts[next];
|
|
@@ -197,8 +264,19 @@ export function jsonFetchBody(body) {
|
|
|
197
264
|
controller.close();
|
|
198
265
|
return;
|
|
199
266
|
}
|
|
200
|
-
|
|
201
|
-
|
|
267
|
+
let end = Math.min(part.length, offset + STREAM_CHUNK_CHARS);
|
|
268
|
+
// 不把成对代理项切开(片段里只有成对的:孤立代理项已被原生转义)
|
|
269
|
+
const last = part.charCodeAt(end - 1);
|
|
270
|
+
if (end < part.length && last >= 0xd800 && last <= 0xdbff)
|
|
271
|
+
end--;
|
|
272
|
+
const piece = offset === 0 && end === part.length ? part : part.slice(offset, end);
|
|
273
|
+
if (end === part.length) {
|
|
274
|
+
parts[next++] = ""; // 发完即放手,片段(拼出来的小文本)可尽早回收
|
|
275
|
+
offset = 0;
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
offset = end;
|
|
279
|
+
controller.enqueue(Buffer.from(piece, "utf8"));
|
|
202
280
|
},
|
|
203
281
|
cancel() {
|
|
204
282
|
parts.length = 0;
|
|
@@ -25,6 +25,8 @@ export interface ModelMetadata {
|
|
|
25
25
|
toolCall?: boolean;
|
|
26
26
|
/** [ME-D] 按 id 继承的内置目录条目 `provider/id`。 */
|
|
27
27
|
catalog?: string;
|
|
28
|
+
/** 显式 `catalog` / `modelsDev` 引用不命中时的告警(注册表转成 warnings,#154)。 */
|
|
29
|
+
warnings?: string[];
|
|
28
30
|
}
|
|
29
31
|
export type ModelsDevSource = ModelsDevIndex | (() => ModelsDevIndex | undefined) | undefined;
|
|
30
32
|
/** 惰性取索引:只有真的要补字段时才读缓存(启动时没有自定义模型就不读约 2 MB 的缓存)。 */
|
|
@@ -32,9 +34,11 @@ export declare function lazyIndex(source: ModelsDevSource): () => ModelsDevIndex
|
|
|
32
34
|
type EnrichedEntry = Omit<ModelConfig, "modelsDev" | "channels" | "catalog">;
|
|
33
35
|
/**
|
|
34
36
|
* 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
|
|
35
|
-
* `modelsDev` / `channels` / `catalog`
|
|
37
|
+
* `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。显式 `catalog: "provider/id"`
|
|
38
|
+
* 或 `modelsDev` 引用不命中时记入 `metadata.warnings`(`providerId` 用于告警文案);写错的 `catalog`
|
|
39
|
+
* 不回落按 id 继承——用户显式指定,按指定失败处理。
|
|
36
40
|
*/
|
|
37
|
-
export declare function enrichEntry(entry: ModelConfig, index: () => ModelsDevIndex | undefined): {
|
|
41
|
+
export declare function enrichEntry(entry: ModelConfig, index: () => ModelsDevIndex | undefined, providerId?: string): {
|
|
38
42
|
entry: EnrichedEntry;
|
|
39
43
|
metadata: ModelMetadata;
|
|
40
44
|
};
|
|
@@ -79,9 +79,11 @@ function fillLimits(out, hit, sources) {
|
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
81
|
* 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
|
|
82
|
-
* `modelsDev` / `channels` / `catalog`
|
|
82
|
+
* `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。显式 `catalog: "provider/id"`
|
|
83
|
+
* 或 `modelsDev` 引用不命中时记入 `metadata.warnings`(`providerId` 用于告警文案);写错的 `catalog`
|
|
84
|
+
* 不回落按 id 继承——用户显式指定,按指定失败处理。
|
|
83
85
|
*/
|
|
84
|
-
export function enrichEntry(entry, index) {
|
|
86
|
+
export function enrichEntry(entry, index, providerId) {
|
|
85
87
|
const { modelsDev, channels: _channels, catalog: _catalog, ...rest } = entry;
|
|
86
88
|
const sources = {};
|
|
87
89
|
for (const field of ENRICHABLE_FIELDS) {
|
|
@@ -89,11 +91,16 @@ export function enrichEntry(entry, index) {
|
|
|
89
91
|
}
|
|
90
92
|
const metadata = { sources, looked: false };
|
|
91
93
|
const out = { ...rest };
|
|
94
|
+
const warnings = [];
|
|
95
|
+
const ref = `${providerId ?? "?"}/${entry.id}`;
|
|
92
96
|
const hit = catalogHit(entry);
|
|
93
97
|
if (hit !== undefined) {
|
|
94
98
|
metadata.catalog = hit.ref;
|
|
95
99
|
inheritIntrinsic(out, hit, sources);
|
|
96
100
|
}
|
|
101
|
+
else if (typeof entry.catalog === "string") {
|
|
102
|
+
warnings.push(`catalog "${entry.catalog}" for "${ref}" not found; ignored`);
|
|
103
|
+
}
|
|
97
104
|
const explicit = typeof modelsDev === "string" ? modelsDev : hit?.snapshotRef;
|
|
98
105
|
const lookup = modelsDev !== false && (needsLookup(out) || modelsDev !== undefined);
|
|
99
106
|
const idx = lookup ? index() : undefined;
|
|
@@ -127,8 +134,12 @@ export function enrichEntry(entry, index) {
|
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
}
|
|
137
|
+
if (typeof modelsDev === "string" && metadata.looked && metadata.match === undefined)
|
|
138
|
+
warnings.push(`modelsDev "${modelsDev}" for "${ref}" not found`);
|
|
130
139
|
if (hit !== undefined && modelsDev !== false)
|
|
131
140
|
fillLimits(out, hit, sources);
|
|
141
|
+
if (warnings.length > 0)
|
|
142
|
+
metadata.warnings = warnings;
|
|
132
143
|
return { entry: out, metadata };
|
|
133
144
|
}
|
|
134
145
|
/**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `max_tokens` 上限的跨进程缓存:`<dataDir>/models/max-tokens-caps.json`(#152)。
|
|
3
|
+
*
|
|
4
|
+
* 被动修正(apis/max-tokens.ts)学到的 `provider/model` 上限原本只在进程内,重启后每个模型的首个请求仍被
|
|
5
|
+
* 400 拒一次。这里把它写进数据目录:组装注册表时载回进程内的表(进程内已有的键优先),学到新上限就把文件里
|
|
6
|
+
* 未过期的条目加上这一条原子写回(临时文件 + 改名;并发进程后写者赢,丢的一条下次再学)。条目 30 天过期,
|
|
7
|
+
* 过期后重新探测(中转放宽上限时不会一直压低输出);删掉文件即重测。只存模型引用、上限与时间,没有凭据。
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_TOKENS_CACHE_VERSION = 1;
|
|
10
|
+
/** 条目有效期:30 天。 */
|
|
11
|
+
export declare const MAX_TOKENS_CACHE_TTL_MS: number;
|
|
12
|
+
/** 缓存文件位置。 */
|
|
13
|
+
export declare function maxTokensCachePath(dataDir: string): string;
|
|
14
|
+
/** 载入未过期的上限到进程内的表(已有的键不覆盖);返回载入条数。 */
|
|
15
|
+
export declare function loadMaxTokensCaps(dataDir: string, now?: () => number): number;
|
|
16
|
+
/** 把一条新上限并入文件(顺带清掉过期条目);写失败不抛(缓存只是省一次 400)。 */
|
|
17
|
+
export declare function recordMaxTokensCap(dataDir: string, key: string, cap: number, now?: () => number): void;
|
|
18
|
+
/**
|
|
19
|
+
* 载入并订阅:之后学到的上限写回该数据目录。按 `dataDir` 幂等(同一进程重复组装注册表不重复订阅);
|
|
20
|
+
* 返回取消函数(取消后可重新 attach)。
|
|
21
|
+
*/
|
|
22
|
+
export declare function attachMaxTokensCache(dataDir: string, now?: () => number): () => void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `max_tokens` 上限的跨进程缓存:`<dataDir>/models/max-tokens-caps.json`(#152)。
|
|
3
|
+
*
|
|
4
|
+
* 被动修正(apis/max-tokens.ts)学到的 `provider/model` 上限原本只在进程内,重启后每个模型的首个请求仍被
|
|
5
|
+
* 400 拒一次。这里把它写进数据目录:组装注册表时载回进程内的表(进程内已有的键优先),学到新上限就把文件里
|
|
6
|
+
* 未过期的条目加上这一条原子写回(临时文件 + 改名;并发进程后写者赢,丢的一条下次再学)。条目 30 天过期,
|
|
7
|
+
* 过期后重新探测(中转放宽上限时不会一直压低输出);删掉文件即重测。只存模型引用、上限与时间,没有凭据。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { maxTokensCaps, onMaxTokensCap } from "../apis/max-tokens.js";
|
|
12
|
+
export const MAX_TOKENS_CACHE_VERSION = 1;
|
|
13
|
+
/** 条目有效期:30 天。 */
|
|
14
|
+
export const MAX_TOKENS_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
15
|
+
/** 缓存文件位置。 */
|
|
16
|
+
export function maxTokensCachePath(dataDir) {
|
|
17
|
+
return join(dataDir, "models", "max-tokens-caps.json");
|
|
18
|
+
}
|
|
19
|
+
/** 读文件里未过期的条目;不存在、坏 JSON、版本不符或形状不对的条目一律忽略。 */
|
|
20
|
+
function readEntries(dataDir, now) {
|
|
21
|
+
const out = new Map();
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(readFileSync(maxTokensCachePath(dataDir), "utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
30
|
+
return out;
|
|
31
|
+
const file = parsed;
|
|
32
|
+
if (file.version !== MAX_TOKENS_CACHE_VERSION)
|
|
33
|
+
return out;
|
|
34
|
+
if (typeof file.caps !== "object" || file.caps === null)
|
|
35
|
+
return out;
|
|
36
|
+
for (const [key, value] of Object.entries(file.caps)) {
|
|
37
|
+
if (typeof value !== "object" || value === null)
|
|
38
|
+
continue;
|
|
39
|
+
const { cap, learnedAt } = value;
|
|
40
|
+
if (typeof cap !== "number" || !Number.isInteger(cap) || cap < 1)
|
|
41
|
+
continue;
|
|
42
|
+
if (typeof learnedAt !== "number" || !Number.isFinite(learnedAt))
|
|
43
|
+
continue;
|
|
44
|
+
if (now - learnedAt > MAX_TOKENS_CACHE_TTL_MS)
|
|
45
|
+
continue;
|
|
46
|
+
out.set(key, { cap, learnedAt });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** 载入未过期的上限到进程内的表(已有的键不覆盖);返回载入条数。 */
|
|
51
|
+
export function loadMaxTokensCaps(dataDir, now = Date.now) {
|
|
52
|
+
let loaded = 0;
|
|
53
|
+
for (const [key, entry] of readEntries(dataDir, now())) {
|
|
54
|
+
if (maxTokensCaps.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
maxTokensCaps.set(key, entry.cap);
|
|
57
|
+
loaded++;
|
|
58
|
+
}
|
|
59
|
+
return loaded;
|
|
60
|
+
}
|
|
61
|
+
/** 把一条新上限并入文件(顺带清掉过期条目);写失败不抛(缓存只是省一次 400)。 */
|
|
62
|
+
export function recordMaxTokensCap(dataDir, key, cap, now = Date.now) {
|
|
63
|
+
const at = now();
|
|
64
|
+
const entries = readEntries(dataDir, at);
|
|
65
|
+
entries.set(key, { cap, learnedAt: at });
|
|
66
|
+
const path = maxTokensCachePath(dataDir);
|
|
67
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
70
|
+
const caps = Object.fromEntries([...entries].sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify({ version: MAX_TOKENS_CACHE_VERSION, caps }, null, 2)}\n`);
|
|
72
|
+
renameSync(tmp, path);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 只读数据目录等:本进程内的表仍有效
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const attached = new Map();
|
|
79
|
+
/**
|
|
80
|
+
* 载入并订阅:之后学到的上限写回该数据目录。按 `dataDir` 幂等(同一进程重复组装注册表不重复订阅);
|
|
81
|
+
* 返回取消函数(取消后可重新 attach)。
|
|
82
|
+
*/
|
|
83
|
+
export function attachMaxTokensCache(dataDir, now = Date.now) {
|
|
84
|
+
const existing = attached.get(dataDir);
|
|
85
|
+
if (existing !== undefined)
|
|
86
|
+
return existing;
|
|
87
|
+
loadMaxTokensCaps(dataDir, now);
|
|
88
|
+
const unsubscribe = onMaxTokensCap((key, cap) => recordMaxTokensCap(dataDir, key, cap, now));
|
|
89
|
+
const detach = () => {
|
|
90
|
+
unsubscribe();
|
|
91
|
+
attached.delete(dataDir);
|
|
92
|
+
};
|
|
93
|
+
attached.set(dataDir, detach);
|
|
94
|
+
return detach;
|
|
95
|
+
}
|
|
@@ -256,9 +256,9 @@ export class ProviderRegistry {
|
|
|
256
256
|
const channelsOf = (wanted, modelId) => modelChannels(wanted, provider.channels ?? [], provider.defaultChannel ?? "", (name) => this.warn(`model "${id}/${modelId}" channel "${name}" not found; ignored`));
|
|
257
257
|
for (const entry of config.models ?? []) {
|
|
258
258
|
// 模型级 api(第三波 §2.3):同一中转下不同模型走不同协议;缺省沿用供应商(或渠道)的。
|
|
259
|
-
const { entry: filled, metadata } = enrichEntry(entry, this.modelsDev);
|
|
260
|
-
|
|
261
|
-
this.warn(
|
|
259
|
+
const { entry: filled, metadata } = enrichEntry(entry, this.modelsDev, id);
|
|
260
|
+
for (const warning of metadata.warnings ?? [])
|
|
261
|
+
this.warn(warning);
|
|
262
262
|
const model = withCustomDefaults(filled, id, entry.api ?? provider.api);
|
|
263
263
|
// 内置供应商上没写 channels 的模型:定渠道时挂全部渠道(settleBuiltinChannels)
|
|
264
264
|
if (provider.channels !== undefined && (entry.channels !== undefined || !provider.builtin))
|