dsh-model-switch 0.0.0-stage → 0.4.17
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 +173 -0
- package/IMPLEMENTATION_PLAN.md +40 -0
- package/LICENSE +21 -0
- package/PRODUCT.md +33 -0
- package/README.md +196 -3
- package/README.zh.md +194 -0
- package/cordis.patch.yml +18 -0
- package/docs/screenshots/composer-picker.png +0 -0
- package/docs/screenshots/plan-review.png +0 -0
- package/docs/screenshots/settings-subagent.png +0 -0
- package/lib/adapter-registry.js +59 -0
- package/lib/capabilities.js +73 -0
- package/lib/client.js +3956 -0
- package/lib/index.js +1030 -0
- package/lib/main-settings.js +30 -0
- package/lib/subagent-route-policy.js +55 -0
- package/lib/subagent-runtime.js +186 -0
- package/lib/types/adapter-registry.d.ts +60 -0
- package/lib/types/capabilities-rpc.d.ts +12 -0
- package/lib/types/capabilities.d.ts +22 -0
- package/lib/types/client/ModelSwitchSettings.d.ts +32 -0
- package/lib/types/client/antigravity-catalog.d.ts +30 -0
- package/lib/types/client/derived-config-form.d.ts +10 -0
- package/lib/types/client/derived-settings-scope.d.ts +4 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/locales.d.ts +45 -0
- package/lib/types/client/main-row-controller.d.ts +49 -0
- package/lib/types/client/nav-icon.d.ts +3 -0
- package/lib/types/client/picker/ComposerPicker.d.ts +47 -0
- package/lib/types/client/picker/PickerDirectory.d.ts +31 -0
- package/lib/types/client/picker/PickerSeatBoundary.d.ts +7 -0
- package/lib/types/client/picker/PlanReviewCard.d.ts +37 -0
- package/lib/types/client/picker/RetryBoundary.d.ts +20 -0
- package/lib/types/client/picker/external-catalog.d.ts +14 -0
- package/lib/types/client/picker/install.d.ts +23 -0
- package/lib/types/client/picker/locales.d.ts +104 -0
- package/lib/types/client/picker/main-default-restore.d.ts +56 -0
- package/lib/types/client/picker/popup-dismissal.d.ts +22 -0
- package/lib/types/client/picker/useComposerPickerSurface.d.ts +25 -0
- package/lib/types/client/plugin-rpc.d.ts +10 -0
- package/lib/types/client/provider-directory.d.ts +23 -0
- package/lib/types/client/remote-settings-scope.d.ts +45 -0
- package/lib/types/client/runtime-lock.d.ts +68 -0
- package/lib/types/client/search-capabilities.d.ts +21 -0
- package/lib/types/client-contract.d.ts +51 -0
- package/lib/types/compatibility.d.ts +38 -0
- package/lib/types/deepseek-search-adapter.d.ts +14 -0
- package/lib/types/host-runtime.d.ts +78 -0
- package/lib/types/host-settings.d.ts +49 -0
- package/lib/types/image-tool.d.ts +17 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/main-settings.d.ts +9 -0
- package/lib/types/picker/family.d.ts +108 -0
- package/lib/types/picker/pending-dismissal.d.ts +34 -0
- package/lib/types/picker/plan-review.d.ts +57 -0
- package/lib/types/picker/public.d.ts +5 -0
- package/lib/types/picker/selection-feedback.d.ts +2 -0
- package/lib/types/runtime-capabilities.d.ts +50 -0
- package/lib/types/search-provider.d.ts +23 -0
- package/lib/types/subagent-route-policy.d.ts +26 -0
- package/lib/types/subagent-runtime.d.ts +51 -0
- package/lib/types/switch-compact.d.ts +72 -0
- package/package.json +342 -6
package/lib/index.js
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { ModelSwitchAdapterRegistry } from "./adapter-registry.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
import { clientRequestSchema } from "@deepseek-ai/dsh-client-connection";
|
|
7
|
+
import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
|
|
8
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
9
|
+
import { Config as Config$1, DEEPSEEK_DEFAULT_API_VERSION, DEEPSEEK_DEFAULT_BASE_URL, DEEPSEEK_DEFAULT_MAX_TOKENS, DEEPSEEK_DEFAULT_MAX_USES, DEEPSEEK_DEFAULT_MODEL, DEEPSEEK_PROVIDER_ID, DeepSeekSearchProvider, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE } from "@deepseek-ai/dsh-web-search-deepseek";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
|
|
13
|
+
import { boundContextSummary, createMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
14
|
+
import { canonicalHeader } from "@deepseek-ai/dsh-session";
|
|
15
|
+
//#region lib/types/host-settings.js
|
|
16
|
+
const Config = z.object({
|
|
17
|
+
subagentMode: z.union(["follow-main", "fixed"]).default("follow-main").volatile(),
|
|
18
|
+
subagentProvider: z.string().volatile(),
|
|
19
|
+
subagentModel: z.string().volatile(),
|
|
20
|
+
subagentReasoningEffort: z.string().volatile(),
|
|
21
|
+
searchProvider: z.string().volatile(),
|
|
22
|
+
searchModel: z.string().volatile(),
|
|
23
|
+
imageProvider: z.string().volatile(),
|
|
24
|
+
imageModel: z.string().volatile(),
|
|
25
|
+
compactOnSwitch: z.boolean().default(true).volatile()
|
|
26
|
+
});
|
|
27
|
+
function readConfig(config) {
|
|
28
|
+
const subagentProvider = config.subagentProvider.get();
|
|
29
|
+
const subagentModel = config.subagentModel.get();
|
|
30
|
+
const subagentReasoningEffort = config.subagentReasoningEffort.get();
|
|
31
|
+
const searchProvider = config.searchProvider.get();
|
|
32
|
+
const searchModel = config.searchModel.get();
|
|
33
|
+
const imageProvider = config.imageProvider.get();
|
|
34
|
+
const imageModel = config.imageModel.get();
|
|
35
|
+
return {
|
|
36
|
+
subagentMode: config.subagentMode.get(),
|
|
37
|
+
...subagentProvider === void 0 ? {} : { subagentProvider },
|
|
38
|
+
...subagentModel === void 0 ? {} : { subagentModel },
|
|
39
|
+
...subagentReasoningEffort === void 0 ? {} : { subagentReasoningEffort },
|
|
40
|
+
...searchProvider === void 0 ? {} : { searchProvider },
|
|
41
|
+
...searchModel === void 0 ? {} : { searchModel },
|
|
42
|
+
...imageProvider === void 0 ? {} : { imageProvider },
|
|
43
|
+
...imageModel === void 0 ? {} : { imageModel },
|
|
44
|
+
compactOnSwitch: config.compactOnSwitch.get()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region lib/types/runtime-capabilities.js
|
|
49
|
+
const RUNTIME_CAPABILITIES = Object.freeze({
|
|
50
|
+
mainDefaults: Object.freeze({ available: true }),
|
|
51
|
+
settings: Object.freeze({ available: true }),
|
|
52
|
+
centralSubagentRouting: Object.freeze({ available: true }),
|
|
53
|
+
packagedPresetRoots: Object.freeze({
|
|
54
|
+
available: false,
|
|
55
|
+
reason: "packaged-preset-roots"
|
|
56
|
+
}),
|
|
57
|
+
toolOwnerSuppression: Object.freeze({
|
|
58
|
+
available: false,
|
|
59
|
+
reason: "tool-owner-suppression"
|
|
60
|
+
}),
|
|
61
|
+
searchProviderAdapters: Object.freeze({
|
|
62
|
+
available: false,
|
|
63
|
+
reason: "search-provider-adapters",
|
|
64
|
+
providers: Object.freeze([])
|
|
65
|
+
}),
|
|
66
|
+
visionProviderAdapters: Object.freeze({
|
|
67
|
+
available: false,
|
|
68
|
+
reason: "vision-provider-adapters"
|
|
69
|
+
}),
|
|
70
|
+
imageProviderAdapters: Object.freeze({
|
|
71
|
+
available: true,
|
|
72
|
+
providers: Object.freeze(["codex", "grok"])
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region lib/types/search-provider.js
|
|
77
|
+
const MODEL_SWITCH_SEARCH_PROVIDER_ID = "model-switch";
|
|
78
|
+
function selected$1(settings) {
|
|
79
|
+
const provider = settings.searchProvider?.trim();
|
|
80
|
+
const model = settings.searchModel?.trim();
|
|
81
|
+
if (provider === void 0 || provider === "" || model === void 0 || model === "") return void 0;
|
|
82
|
+
return {
|
|
83
|
+
provider,
|
|
84
|
+
model
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Thin official WebSearchProvider that resolves Model Switch routing at execution time. */
|
|
88
|
+
var ModelSwitchSearchProvider = class {
|
|
89
|
+
settings;
|
|
90
|
+
adapters;
|
|
91
|
+
id = MODEL_SWITCH_SEARCH_PROVIDER_ID;
|
|
92
|
+
constructor(settings, adapters) {
|
|
93
|
+
this.settings = settings;
|
|
94
|
+
this.adapters = adapters;
|
|
95
|
+
}
|
|
96
|
+
available() {
|
|
97
|
+
const route = selected$1(this.settings());
|
|
98
|
+
if (route === void 0) return false;
|
|
99
|
+
const adapter = this.adapters.get(route.provider)?.search;
|
|
100
|
+
return adapter !== void 0 && adapter.supportsModel(route.model);
|
|
101
|
+
}
|
|
102
|
+
async search(request, signal) {
|
|
103
|
+
const route = selected$1(this.settings());
|
|
104
|
+
if (route === void 0) throw new Error("search provider and model must be configured in Model Switch");
|
|
105
|
+
const adapter = this.adapters.get(route.provider)?.search;
|
|
106
|
+
if (adapter === void 0) throw new Error("missing search adapter: " + route.provider);
|
|
107
|
+
if (!adapter.supportsModel(route.model)) throw new Error("search model is not supported by adapter: " + route.provider + "/" + route.model);
|
|
108
|
+
return adapter.search(route.model, request, signal);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
function installModelSwitchSearchProvider(ctx, runtime) {
|
|
112
|
+
ctx.inject(["web"], (scope) => scope.effect(() => scope.web.registerSearchProvider(new ModelSwitchSearchProvider(() => runtime.currentSettings(), runtime.adapters)), "Model Switch: register thin Search provider"));
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region lib/types/image-tool.js
|
|
116
|
+
const GENERATE_IMAGE_TOOL_NAME = "generate_image";
|
|
117
|
+
function selected(settings) {
|
|
118
|
+
const provider = settings.imageProvider?.trim();
|
|
119
|
+
const model = settings.imageModel?.trim();
|
|
120
|
+
if (provider === void 0 || provider === "" || model === void 0 || model === "") throw new Error("image provider and model must be configured in Model Switch");
|
|
121
|
+
return {
|
|
122
|
+
provider,
|
|
123
|
+
model
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function optional(value) {
|
|
127
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
128
|
+
}
|
|
129
|
+
function validateGenerated(value) {
|
|
130
|
+
if (typeof value !== "object" || value === null) throw new Error("image adapter returned invalid metadata");
|
|
131
|
+
if (typeof value.path !== "string" || value.path.trim() === "") throw new Error("image adapter returned empty path");
|
|
132
|
+
if (![
|
|
133
|
+
"image/png",
|
|
134
|
+
"image/jpeg",
|
|
135
|
+
"image/webp",
|
|
136
|
+
"image/gif"
|
|
137
|
+
].includes(value.mediaType)) throw new Error("image adapter returned invalid media type");
|
|
138
|
+
if (!Number.isInteger(value.width) || value.width <= 0 || !Number.isInteger(value.height) || value.height <= 0) throw new Error("image adapter returned invalid dimensions");
|
|
139
|
+
if (value.bytes !== void 0 && (!Number.isInteger(value.bytes) || value.bytes <= 0)) throw new Error("image adapter returned invalid byte count");
|
|
140
|
+
if (value.attachmentId !== void 0 && (typeof value.attachmentId !== "string" || value.attachmentId.trim() === "")) throw new Error("image adapter returned invalid attachment id");
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
function createGenerateImageTool(runtime) {
|
|
144
|
+
const schemaProvider = runtime.currentSettings().imageProvider?.trim();
|
|
145
|
+
const parameters = {
|
|
146
|
+
prompt: {
|
|
147
|
+
type: "string",
|
|
148
|
+
required: true,
|
|
149
|
+
description: "Detailed image prompt."
|
|
150
|
+
},
|
|
151
|
+
path: {
|
|
152
|
+
type: "string",
|
|
153
|
+
description: "Optional workspace-relative destination."
|
|
154
|
+
},
|
|
155
|
+
...schemaProvider === "codex" ? {
|
|
156
|
+
source: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "Optional source image for editing. Omit for a new image."
|
|
159
|
+
},
|
|
160
|
+
outputFormat: {
|
|
161
|
+
type: "string",
|
|
162
|
+
enum: [
|
|
163
|
+
"png",
|
|
164
|
+
"jpeg",
|
|
165
|
+
"webp"
|
|
166
|
+
],
|
|
167
|
+
description: "Output format."
|
|
168
|
+
}
|
|
169
|
+
} : {},
|
|
170
|
+
...schemaProvider === "grok" ? { aspectRatio: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "Optional Grok aspect ratio, for example 1:1 or 16:9."
|
|
173
|
+
} } : {}
|
|
174
|
+
};
|
|
175
|
+
return defineTool({
|
|
176
|
+
name: GENERATE_IMAGE_TOOL_NAME,
|
|
177
|
+
description: schemaProvider === "codex" ? "Generate or edit a raster image through the Codex model selected in Model Switch." : schemaProvider === "grok" ? "Generate a raster image through the Grok model selected in Model Switch." : "Generate a raster image through the provider and model selected in Model Switch.",
|
|
178
|
+
parameters,
|
|
179
|
+
output: {
|
|
180
|
+
schema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
additionalProperties: false,
|
|
183
|
+
properties: {
|
|
184
|
+
provider: {
|
|
185
|
+
type: "string",
|
|
186
|
+
required: true
|
|
187
|
+
},
|
|
188
|
+
model: {
|
|
189
|
+
type: "string",
|
|
190
|
+
required: true
|
|
191
|
+
},
|
|
192
|
+
path: {
|
|
193
|
+
type: "string",
|
|
194
|
+
required: true
|
|
195
|
+
},
|
|
196
|
+
mediaType: {
|
|
197
|
+
type: "string",
|
|
198
|
+
required: true
|
|
199
|
+
},
|
|
200
|
+
width: {
|
|
201
|
+
type: "integer",
|
|
202
|
+
required: true
|
|
203
|
+
},
|
|
204
|
+
height: {
|
|
205
|
+
type: "integer",
|
|
206
|
+
required: true
|
|
207
|
+
},
|
|
208
|
+
bytes: { type: "integer" },
|
|
209
|
+
attachmentId: { type: "string" },
|
|
210
|
+
name: { type: "string" },
|
|
211
|
+
revisedPrompt: { type: "string" }
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
render: (_args, value) => [{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: value.path
|
|
217
|
+
}]
|
|
218
|
+
},
|
|
219
|
+
async execute(rawArgs, execution) {
|
|
220
|
+
const args = rawArgs;
|
|
221
|
+
if (typeof args.prompt !== "string" || args.prompt.trim() === "") throw new Error("image prompt must be non-empty");
|
|
222
|
+
const route = selected(runtime.currentSettings());
|
|
223
|
+
const path = optional(args.path);
|
|
224
|
+
const source = optional(args.source);
|
|
225
|
+
const aspectRatio = optional(args.aspectRatio);
|
|
226
|
+
const outputFormat = optional(args.outputFormat);
|
|
227
|
+
if (route.provider === "codex" && aspectRatio !== void 0) throw new Error("Codex image generation does not accept aspectRatio");
|
|
228
|
+
if (route.provider === "grok" && (source !== void 0 || outputFormat !== void 0)) throw new Error("Grok image generation does not accept source or outputFormat");
|
|
229
|
+
if (route.provider !== "codex" && route.provider !== "grok") throw new Error("image provider must be codex or grok");
|
|
230
|
+
const adapter = runtime.adapters.get(route.provider)?.image;
|
|
231
|
+
if (adapter === void 0) throw new Error("missing image adapter: " + route.provider);
|
|
232
|
+
if (!adapter.supportsModel(route.model)) throw new Error("image model is not supported by adapter: " + route.provider + "/" + route.model);
|
|
233
|
+
const request = {
|
|
234
|
+
prompt: args.prompt,
|
|
235
|
+
...path === void 0 ? {} : { path },
|
|
236
|
+
...source === void 0 ? {} : { source },
|
|
237
|
+
...outputFormat === void 0 ? {} : { outputFormat },
|
|
238
|
+
...aspectRatio === void 0 ? {} : { aspectRatio }
|
|
239
|
+
};
|
|
240
|
+
const generated = validateGenerated(await adapter.generate(route.model, request, execution));
|
|
241
|
+
return {
|
|
242
|
+
provider: route.provider,
|
|
243
|
+
model: route.model,
|
|
244
|
+
...generated
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Registers one provider-specific schema and replaces it transactionally on Settings changes. */
|
|
250
|
+
function installGenerateImageTool(ctx, runtime) {
|
|
251
|
+
let register;
|
|
252
|
+
let current;
|
|
253
|
+
let tail = Promise.resolve();
|
|
254
|
+
const replace = () => {
|
|
255
|
+
if (register === void 0) return;
|
|
256
|
+
const next = createGenerateImageTool(runtime);
|
|
257
|
+
const previous = current;
|
|
258
|
+
previous?.dispose();
|
|
259
|
+
try {
|
|
260
|
+
current = {
|
|
261
|
+
tool: next,
|
|
262
|
+
dispose: register(next)
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
current = previous === void 0 ? void 0 : {
|
|
266
|
+
tool: previous.tool,
|
|
267
|
+
dispose: register(previous.tool)
|
|
268
|
+
};
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const controller = { reconcile() {
|
|
273
|
+
const operation = tail.then(replace, replace);
|
|
274
|
+
tail = operation.catch(() => {});
|
|
275
|
+
return operation;
|
|
276
|
+
} };
|
|
277
|
+
ctx.inject(["tools"], (scope) => {
|
|
278
|
+
register = (tool) => scope.tools.register(tool);
|
|
279
|
+
replace();
|
|
280
|
+
return () => {
|
|
281
|
+
current?.dispose();
|
|
282
|
+
current = void 0;
|
|
283
|
+
register = void 0;
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
return controller;
|
|
287
|
+
}
|
|
288
|
+
function statusResponse(status) {
|
|
289
|
+
return new Response(status === 415 ? "Unsupported Media Type" : status === 500 ? "Internal Server Error" : "Bad Request", { status });
|
|
290
|
+
}
|
|
291
|
+
function binaryResponse(rpcId, result) {
|
|
292
|
+
const response = {
|
|
293
|
+
type: "server-response",
|
|
294
|
+
rpcId,
|
|
295
|
+
result
|
|
296
|
+
};
|
|
297
|
+
if (!result.ok) return Response.json(response);
|
|
298
|
+
const { attachments, ...value } = result;
|
|
299
|
+
const envelope = {
|
|
300
|
+
...response,
|
|
301
|
+
result: value
|
|
302
|
+
};
|
|
303
|
+
if (attachments === void 0 || attachments.length === 0) return Response.json(envelope);
|
|
304
|
+
const parts = new FormData();
|
|
305
|
+
const attachmentMetadata = attachments.map((attachment, index) => {
|
|
306
|
+
const part = `bytes-${index}`;
|
|
307
|
+
parts.set(part, new Blob([new Uint8Array(attachment.bytes)]));
|
|
308
|
+
return {
|
|
309
|
+
path: [...attachment.path],
|
|
310
|
+
codec: "bytes",
|
|
311
|
+
part
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
parts.set("metadata", JSON.stringify({
|
|
315
|
+
...envelope,
|
|
316
|
+
attachments: attachmentMetadata
|
|
317
|
+
}));
|
|
318
|
+
return new Response(parts);
|
|
319
|
+
}
|
|
320
|
+
async function handleFetchRpc(request, handler, operator) {
|
|
321
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return statusResponse(415);
|
|
322
|
+
let body;
|
|
323
|
+
try {
|
|
324
|
+
body = await request.json();
|
|
325
|
+
} catch {
|
|
326
|
+
return statusResponse(400);
|
|
327
|
+
}
|
|
328
|
+
const parsed = clientRequestSchema.safeParse(body);
|
|
329
|
+
if (!parsed.success || parsed.data.method !== "plugin-rpc/model-switch") return statusResponse(400);
|
|
330
|
+
const wrapped = parsed.data.payload;
|
|
331
|
+
if (typeof wrapped !== "object" || wrapped === null || Array.isArray(wrapped)) return statusResponse(400);
|
|
332
|
+
if (Object.keys(wrapped).some((key) => key !== "endpoint" && key !== "payload")) return statusResponse(400);
|
|
333
|
+
const { endpoint, payload } = wrapped;
|
|
334
|
+
if (typeof endpoint !== "string") return statusResponse(400);
|
|
335
|
+
try {
|
|
336
|
+
const result = await handler(endpoint, payload, request.signal, operator);
|
|
337
|
+
return binaryResponse(parsed.data.rpcId, result);
|
|
338
|
+
} catch {
|
|
339
|
+
return statusResponse(500);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Bounded long-poll on the existing registry; each caller owns and disposes its subscription. */
|
|
343
|
+
function capabilitiesRpc(registry, snapshot, lifetime) {
|
|
344
|
+
let revision = 0;
|
|
345
|
+
let previous;
|
|
346
|
+
const read = () => {
|
|
347
|
+
const capabilities = snapshot();
|
|
348
|
+
const signature = JSON.stringify(capabilities);
|
|
349
|
+
if (previous !== void 0 && signature !== previous) revision++;
|
|
350
|
+
previous = signature;
|
|
351
|
+
return {
|
|
352
|
+
revision,
|
|
353
|
+
capabilities
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
return async (endpoint, payload, signal) => {
|
|
357
|
+
if (endpoint !== "capabilities") return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: {
|
|
360
|
+
details: {},
|
|
361
|
+
code: "unknown-endpoint",
|
|
362
|
+
message: "Unknown Model Switch endpoint"
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload) || Object.keys(payload).some((key) => key !== "revision")) return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: {
|
|
368
|
+
details: {},
|
|
369
|
+
code: "invalid-request",
|
|
370
|
+
message: "Expected optional revision"
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const requested = payload.revision;
|
|
374
|
+
if (requested !== void 0 && (typeof requested !== "number" || !Number.isSafeInteger(requested) || requested < 0)) return {
|
|
375
|
+
ok: false,
|
|
376
|
+
error: {
|
|
377
|
+
details: {},
|
|
378
|
+
code: "invalid-request",
|
|
379
|
+
message: "Invalid capabilities revision"
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
if (requested === read().revision && !lifetime.aborted && !signal.aborted) await new Promise((resolve) => {
|
|
383
|
+
const finish = () => {
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
unsubscribe();
|
|
386
|
+
signal.removeEventListener("abort", finish);
|
|
387
|
+
lifetime.removeEventListener("abort", finish);
|
|
388
|
+
resolve();
|
|
389
|
+
};
|
|
390
|
+
const unsubscribe = registry.subscribe(finish);
|
|
391
|
+
const timer = setTimeout(finish, 2e4);
|
|
392
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
393
|
+
lifetime.addEventListener("abort", finish, { once: true });
|
|
394
|
+
});
|
|
395
|
+
if (lifetime.aborted || signal.aborted) return {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: {
|
|
398
|
+
details: {},
|
|
399
|
+
code: "cancelled",
|
|
400
|
+
message: "Capabilities subscription closed"
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
value: read()
|
|
406
|
+
};
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function installCapabilitiesRpc(ctx, registry, snapshot) {
|
|
410
|
+
ctx.inject(["connection"], (scope) => scope.effect(() => {
|
|
411
|
+
const lifetime = new AbortController();
|
|
412
|
+
const handler = capabilitiesRpc(registry, snapshot, lifetime.signal);
|
|
413
|
+
const dispose = scope.connection.fetch.register({
|
|
414
|
+
path: "/api/plugin-rpc/model-switch",
|
|
415
|
+
methods: ["POST"],
|
|
416
|
+
requestBody: "buffered",
|
|
417
|
+
fetch: (request) => handleFetchRpc(request, handler, scope.connection.operator)
|
|
418
|
+
});
|
|
419
|
+
return async () => {
|
|
420
|
+
lifetime.abort();
|
|
421
|
+
await dispose();
|
|
422
|
+
};
|
|
423
|
+
}, "Model Switch: lifecycle-owned search capability metadata"));
|
|
424
|
+
}
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region lib/types/deepseek-search-adapter.js
|
|
427
|
+
const DEFAULT_API_KEY_REF = "DEEPSEEK_API_KEY";
|
|
428
|
+
const SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
|
|
429
|
+
const DEEPSEEK_SEARCH_MODELS = [{
|
|
430
|
+
id: DEEPSEEK_DEFAULT_MODEL,
|
|
431
|
+
name: "DeepSeek-V4-Flash"
|
|
432
|
+
}, {
|
|
433
|
+
id: "deepseek-v4-pro",
|
|
434
|
+
name: "DeepSeek-V4-Pro"
|
|
435
|
+
}];
|
|
436
|
+
function readDeepSeekSection(ctx) {
|
|
437
|
+
const loader = ctx.get("loader", false);
|
|
438
|
+
let configured;
|
|
439
|
+
for (const entry of loader?.entries() ?? []) {
|
|
440
|
+
if (entry.id !== WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE) continue;
|
|
441
|
+
configured = entry.fiber?.config;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
const section = configured ?? Config$1({});
|
|
445
|
+
const apiKey = section.apiKey.get();
|
|
446
|
+
const baseURL = section.baseURL.get();
|
|
447
|
+
return {
|
|
448
|
+
...apiKey === void 0 ? {} : { apiKey },
|
|
449
|
+
apiKeyEnv: section.apiKeyEnv.get(),
|
|
450
|
+
...baseURL === void 0 ? {} : { baseURL },
|
|
451
|
+
model: section.model.get(),
|
|
452
|
+
apiVersion: section.apiVersion.get(),
|
|
453
|
+
maxTokens: section.maxTokens.get(),
|
|
454
|
+
maxUses: section.maxUses.get()
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function keyRef(section) {
|
|
458
|
+
const name = section.apiKeyEnv ?? DEFAULT_API_KEY_REF;
|
|
459
|
+
if (!isCredentialRefName(name)) throw new Error("invalid web-search-deepseek apiKeyEnv credential reference");
|
|
460
|
+
return credentialRef(name);
|
|
461
|
+
}
|
|
462
|
+
function recordSearchRequest(ctx, request) {
|
|
463
|
+
(ctx.get("agents")?.currentInitiator?.())?.session?.append("web/deepseek-search-llm-request", request);
|
|
464
|
+
}
|
|
465
|
+
function toProviderOptions(ctx, section, model) {
|
|
466
|
+
const apiKeyEnv = keyRef(section);
|
|
467
|
+
const literalApiKey = section.apiKey !== void 0 && section.apiKey.length > 0 ? section.apiKey : void 0;
|
|
468
|
+
return {
|
|
469
|
+
...literalApiKey === void 0 ? {} : { apiKey: literalApiKey },
|
|
470
|
+
resolveApiKey: async () => {
|
|
471
|
+
const credentials = ctx.get("credentials");
|
|
472
|
+
if (credentials !== void 0) return (await credentials.resolve(apiKeyEnv))?.value;
|
|
473
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
474
|
+
return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
|
|
475
|
+
},
|
|
476
|
+
apiKeyEnv,
|
|
477
|
+
baseURL: section.baseURL ?? launchEnvironmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
|
|
478
|
+
model,
|
|
479
|
+
apiVersion: section.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
|
|
480
|
+
maxTokens: section.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS,
|
|
481
|
+
maxUses: section.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES,
|
|
482
|
+
recordRequest: (request) => {
|
|
483
|
+
recordSearchRequest(ctx, request);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
var DeepSeekSearchAdapter = class {
|
|
488
|
+
ctx;
|
|
489
|
+
provider = DEEPSEEK_PROVIDER_ID;
|
|
490
|
+
label = "DeepSeek";
|
|
491
|
+
models = DEEPSEEK_SEARCH_MODELS;
|
|
492
|
+
constructor(ctx) {
|
|
493
|
+
this.ctx = ctx;
|
|
494
|
+
}
|
|
495
|
+
supportsModel(model) {
|
|
496
|
+
return DEEPSEEK_SEARCH_MODELS.some((known) => known.id === model);
|
|
497
|
+
}
|
|
498
|
+
async search(model, request, signal) {
|
|
499
|
+
if (!this.supportsModel(model)) throw new Error("search model is not supported by adapter: " + DEEPSEEK_PROVIDER_ID + "/" + model);
|
|
500
|
+
const options = toProviderOptions(this.ctx, readDeepSeekSection(this.ctx), model);
|
|
501
|
+
return new DeepSeekSearchProvider(() => options).search(request, signal);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
function installDeepSeekSearchAdapter(ctx) {
|
|
505
|
+
ctx.inject(["modelSwitch"], (scope) => scope.effect(() => scope.modelSwitch.adapters.register({
|
|
506
|
+
provider: DEEPSEEK_PROVIDER_ID,
|
|
507
|
+
search: new DeepSeekSearchAdapter(ctx)
|
|
508
|
+
}), "Model Switch: register DeepSeek search adapter"));
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region lib/types/compatibility.js
|
|
512
|
+
/**
|
|
513
|
+
* Classify one runtime without treating the verified table as an allowlist.
|
|
514
|
+
* @param version - Resolved DSH runtime version.
|
|
515
|
+
* @param verified - Releases with direct compatibility evidence.
|
|
516
|
+
* @param blocklist - Versions excluded after reproduced failures.
|
|
517
|
+
* @returns The fail-open mount decision.
|
|
518
|
+
*/
|
|
519
|
+
function classifyDshRuntime(version, verified, blocklist = {}) {
|
|
520
|
+
const reason = blocklist[version];
|
|
521
|
+
if (typeof reason === "string" && reason.trim() !== "") return {
|
|
522
|
+
kind: "blocked",
|
|
523
|
+
reason
|
|
524
|
+
};
|
|
525
|
+
return verified.has(version) ? { kind: "verified" } : { kind: "unverified" };
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Apply the fail-open decision and emit at most one visible warning.
|
|
529
|
+
* @param logger - Host logger receiving compatibility warnings.
|
|
530
|
+
* @param pluginName - Plugin identifier used in diagnostics.
|
|
531
|
+
* @param version - Resolved DSH runtime version.
|
|
532
|
+
* @param verified - Releases with direct compatibility evidence.
|
|
533
|
+
* @param blocklist - Versions excluded after reproduced failures.
|
|
534
|
+
* @returns Whether the host mount should continue.
|
|
535
|
+
*/
|
|
536
|
+
function shouldMountDshRuntime(logger, pluginName, version, verified, blocklist = {}) {
|
|
537
|
+
const decision = classifyDshRuntime(version, verified, blocklist);
|
|
538
|
+
if (decision.kind === "blocked") {
|
|
539
|
+
logger.warn(`[${pluginName}] blocked on DSH ${version}: ${decision.reason}; see package.json#dsh.compatibility.blocklist`);
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (decision.kind === "unverified") logger.warn(`[${pluginName}] best-effort on unverified runtime ${version}`);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
function readManifest() {
|
|
546
|
+
try {
|
|
547
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
548
|
+
} catch {
|
|
549
|
+
return {};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function packageVersion(packageName) {
|
|
553
|
+
try {
|
|
554
|
+
const require = createRequire(import.meta.url);
|
|
555
|
+
let directory = dirname(require.resolve(packageName));
|
|
556
|
+
for (;;) {
|
|
557
|
+
try {
|
|
558
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
559
|
+
if (typeof manifest.version === "string" && manifest.version !== "") return manifest.version;
|
|
560
|
+
} catch {}
|
|
561
|
+
const parent = dirname(directory);
|
|
562
|
+
if (parent === directory) return void 0;
|
|
563
|
+
directory = parent;
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Warn once for an unknown runtime while keeping the normal host mount path.
|
|
571
|
+
* @param logger - Host logger receiving compatibility warnings.
|
|
572
|
+
* @param pluginName - Plugin identifier used in diagnostics.
|
|
573
|
+
* @param candidates - DSH peer packages used to resolve the host version.
|
|
574
|
+
* @returns Whether the host mount should continue.
|
|
575
|
+
*/
|
|
576
|
+
function allowDshRuntime(logger, pluginName, candidates) {
|
|
577
|
+
const version = process.env.DSH_VERSION?.trim() || candidates.map(packageVersion).find((value) => value !== void 0) || "unknown";
|
|
578
|
+
const compatibility = readManifest().dsh?.compatibility;
|
|
579
|
+
return shouldMountDshRuntime(logger, pluginName, version, new Set(Object.entries(compatibility?.dshReleases ?? {}).filter(([, status]) => status === "compatible" || status === "verified").map(([release]) => release)), compatibility?.blocklist);
|
|
580
|
+
}
|
|
581
|
+
//#endregion
|
|
582
|
+
//#region lib/types/switch-compact.js
|
|
583
|
+
/** Send-time context check and optional old-model compaction before a switched request. */
|
|
584
|
+
/** Fraction of the target window reserved for output plus estimator error. */
|
|
585
|
+
const OUTPUT_RESERVE = .2;
|
|
586
|
+
/** Extra tokens on top of the ratio; the meter heuristic is not exact. */
|
|
587
|
+
const ESTIMATE_MARGIN = 64;
|
|
588
|
+
/**
|
|
589
|
+
* Compare two fully specified model routes.
|
|
590
|
+
* @param left - first route, if one was resolved.
|
|
591
|
+
* @param right - second route, if one was resolved.
|
|
592
|
+
* @returns Whether both routes identify the same provider and model.
|
|
593
|
+
*/
|
|
594
|
+
function sameSwitchRoute(left, right) {
|
|
595
|
+
return left !== void 0 && right !== void 0 && left.provider === right.provider && left.model === right.model;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Decide whether a changed, complete route needs send-time context protection.
|
|
599
|
+
* @param enabled - whether the user enabled send-time protection.
|
|
600
|
+
* @param previous - route used by the preceding request.
|
|
601
|
+
* @param selected - route assembled for the pending request.
|
|
602
|
+
* @returns Whether the pending request changes its provider or model.
|
|
603
|
+
*/
|
|
604
|
+
function shouldProtectSwitch(enabled, previous, selected) {
|
|
605
|
+
if (!enabled) return false;
|
|
606
|
+
if (previous === void 0 || selected === void 0) return false;
|
|
607
|
+
return !sameSwitchRoute(previous, selected);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Input budget for the target window. Reserves output (maxTokens or 20%) plus a
|
|
611
|
+
* fixed estimator margin. `undefined` means the window cannot be used.
|
|
612
|
+
* @param contextWindow - adapter-owned combined request/response capacity.
|
|
613
|
+
* @param maxTokens - optional generation cap from the live agent options.
|
|
614
|
+
* @returns Tokens available to the request input, or undefined for an invalid window.
|
|
615
|
+
*/
|
|
616
|
+
function switchInputBudget(contextWindow, maxTokens) {
|
|
617
|
+
if (!(contextWindow > 0) || !Number.isFinite(contextWindow)) return void 0;
|
|
618
|
+
const reserve = Math.max(Math.ceil(contextWindow * OUTPUT_RESERVE), maxTokens ?? 0) + ESTIMATE_MARGIN;
|
|
619
|
+
return Math.max(0, Math.floor(contextWindow - reserve));
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Determine whether an estimated input exceeds its target route budget.
|
|
623
|
+
* @param tokens - estimated input tokens.
|
|
624
|
+
* @param budget - input budget for the target window.
|
|
625
|
+
* @returns Whether the budget is unusable or the estimate is too large.
|
|
626
|
+
*/
|
|
627
|
+
function overSwitchBudget(tokens, budget) {
|
|
628
|
+
return !(budget > 0) || tokens > budget;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Build one transcript-visible Model Switch progress notice.
|
|
632
|
+
* @param text - localized notice text shown in the transcript.
|
|
633
|
+
* @param summary - bounded event summary for durable session metadata.
|
|
634
|
+
* @returns A plugin-authored user message for the session log.
|
|
635
|
+
*/
|
|
636
|
+
function switchNotice(text, summary) {
|
|
637
|
+
return createUserMessage({
|
|
638
|
+
content: [{
|
|
639
|
+
type: "text",
|
|
640
|
+
text
|
|
641
|
+
}],
|
|
642
|
+
source: {
|
|
643
|
+
kind: "model-switch",
|
|
644
|
+
form: "notice",
|
|
645
|
+
summary: boundContextSummary(summary)
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Extract a complete route from partial request configuration.
|
|
651
|
+
* @param value - candidate provider and model values.
|
|
652
|
+
* @returns A complete route, or undefined when either identifier is absent.
|
|
653
|
+
*/
|
|
654
|
+
function routeOf(value) {
|
|
655
|
+
if (value === void 0 || value.provider === void 0 || value.provider.length === 0 || value.model === void 0 || value.model.length === 0) return void 0;
|
|
656
|
+
return {
|
|
657
|
+
provider: value.provider,
|
|
658
|
+
model: value.model
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Install send-time switch protection on the host context.
|
|
663
|
+
* @param ctx - host context that owns agent-loop, llm, and tokenMeter.
|
|
664
|
+
* @param settings - live Model Switch settings; `compactOnSwitch !== false` is on.
|
|
665
|
+
* @param roleOf - provider-role lookup used to exclude native Agent runtimes.
|
|
666
|
+
*/
|
|
667
|
+
function installSwitchCompaction(ctx, settings, roleOf = () => void 0) {
|
|
668
|
+
const assemblies = /* @__PURE__ */ new WeakMap();
|
|
669
|
+
let stopGate;
|
|
670
|
+
ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
|
|
671
|
+
const assembled = await next();
|
|
672
|
+
if (context.agent !== void 0) {
|
|
673
|
+
assemblies.set(context.agent, assembled);
|
|
674
|
+
stopGate?.();
|
|
675
|
+
installGate();
|
|
676
|
+
}
|
|
677
|
+
return assembled;
|
|
678
|
+
});
|
|
679
|
+
function installGate() {
|
|
680
|
+
stopGate = ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
681
|
+
const decision = await next();
|
|
682
|
+
if (decision.kind !== "enter" || signal.aborted) return decision;
|
|
683
|
+
const enabled = settings().compactOnSwitch !== false;
|
|
684
|
+
const previous = routeOf(agent.session.requestHeader()?.config);
|
|
685
|
+
const assembly = assemblies.get(agent);
|
|
686
|
+
const selected = routeOf(assembly === void 0 ? void 0 : {
|
|
687
|
+
...assembly.variables.provider === void 0 ? {} : { provider: assembly.variables.provider },
|
|
688
|
+
...assembly.variables.model === void 0 ? {} : { model: assembly.variables.model }
|
|
689
|
+
});
|
|
690
|
+
if (!shouldProtectSwitch(enabled, previous, selected) || previous === void 0 || selected === void 0) return decision;
|
|
691
|
+
if (roleOf(previous.provider) === "agent" || roleOf(selected.provider) === "agent") return decision;
|
|
692
|
+
return protectSwitch(ctx, agent, previous, selected, assembly, decision, messages, signal);
|
|
693
|
+
}, { prepend: true });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function protectSwitch(ctx, agent, previous, selected, assembly, decision, claimed, signal) {
|
|
697
|
+
const session = agent.session;
|
|
698
|
+
const notice = (text, summary) => {
|
|
699
|
+
session.append("user/message", switchNotice(text, summary), { surfaceOp: "append" });
|
|
700
|
+
};
|
|
701
|
+
const reject = (reason, summary, compacted) => {
|
|
702
|
+
notice(compacted ? `无法继续本次模型切换:压缩已发生,但请求未继续(${reason})。` : `无法继续本次模型切换:${reason}`, summary);
|
|
703
|
+
retainUserClaims(session, claimed);
|
|
704
|
+
return { kind: "reject" };
|
|
705
|
+
};
|
|
706
|
+
notice("正在检查上下文…", "checking context");
|
|
707
|
+
if (assembly === void 0) return reject("无法确定本次请求组装的目标模型。", "switch blocked: no assembled target", false);
|
|
708
|
+
const llm = ctx.get("llm", false);
|
|
709
|
+
if (llm?.resolveModelInfo === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
|
|
710
|
+
let window;
|
|
711
|
+
try {
|
|
712
|
+
const value = (await llm.resolveModelInfo(selected.provider, selected.model, signal)).context?.contextWindow;
|
|
713
|
+
window = typeof value === "number" && value > 0 ? value : void 0;
|
|
714
|
+
} catch {
|
|
715
|
+
window = void 0;
|
|
716
|
+
}
|
|
717
|
+
if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", false);
|
|
718
|
+
if (window === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
|
|
719
|
+
const meter = ctx.get("tokenMeter", false);
|
|
720
|
+
if (meter === void 0 || typeof meter.measure !== "function" || typeof meter.estimateMessage !== "function") return reject("无法估算待发上下文(缺少 token 计量)。", "switch blocked: no token meter", false);
|
|
721
|
+
const header = targetHeader(selected, assembly);
|
|
722
|
+
const previousHeader = session.requestHeader();
|
|
723
|
+
let pressure;
|
|
724
|
+
try {
|
|
725
|
+
pressure = estimateSwitchPressure(meter, session, header, assembly, decision.messages, previousHeader);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
return reject(`无法估算待发上下文(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: estimate failed", false);
|
|
728
|
+
}
|
|
729
|
+
const budget = agent.options.maxTokens === void 0 ? switchInputBudget(window) : switchInputBudget(window, agent.options.maxTokens);
|
|
730
|
+
if (budget === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
|
|
731
|
+
const passed = switchNotice("上下文容量检查通过,无需压缩。", "context check passed");
|
|
732
|
+
const completed = switchNotice("压缩完成,复查通过。", "compacted");
|
|
733
|
+
if (!overSwitchBudget(pressure + meter.estimateMessage(passed), budget)) {
|
|
734
|
+
session.append("user/message", passed, { surfaceOp: "append" });
|
|
735
|
+
return decision;
|
|
736
|
+
}
|
|
737
|
+
let fixedPressure;
|
|
738
|
+
try {
|
|
739
|
+
fixedPressure = estimateFixedInput(meter, session, header, assembly, decision.messages);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return reject(`无法估算待发上下文(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: estimate failed", false);
|
|
742
|
+
}
|
|
743
|
+
if (overSwitchBudget(fixedPressure + meter.estimateMessage(completed), budget)) return reject("新消息或固定请求内容已超过目标模型可用容量。", "switch blocked: fixed input oversized", false);
|
|
744
|
+
notice("当前上下文超过目标模型可用容量,正在使用旧模型压缩…", "compacting with previous model");
|
|
745
|
+
const compactionGeneration = session.surface.replaceGeneration;
|
|
746
|
+
let compacted = false;
|
|
747
|
+
try {
|
|
748
|
+
const result = await compactWithPrevious(ctx, agent, previous, signal);
|
|
749
|
+
compacted = result !== "none" || session.surface.replaceGeneration > compactionGeneration;
|
|
750
|
+
if (result === "none") return reject("没有可压缩的历史。", "switch blocked: nothing to compact", compacted);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
compacted = compacted || session.surface.replaceGeneration > compactionGeneration;
|
|
753
|
+
if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", compacted);
|
|
754
|
+
return reject(`压缩失败(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: compact failed", compacted);
|
|
755
|
+
}
|
|
756
|
+
if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", compacted);
|
|
757
|
+
let again;
|
|
758
|
+
try {
|
|
759
|
+
again = estimateSwitchPressure(meter, session, header, assembly, decision.messages);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
return reject(`压缩后无法复查(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: remeasure failed", compacted);
|
|
762
|
+
}
|
|
763
|
+
if (overSwitchBudget(again + meter.estimateMessage(completed), budget)) return reject("压缩后仍超过目标模型可用容量。", "switch blocked: still over budget", compacted);
|
|
764
|
+
session.append("user/message", completed, { surfaceOp: "append" });
|
|
765
|
+
return decision;
|
|
766
|
+
}
|
|
767
|
+
function targetHeader(selected, assembly) {
|
|
768
|
+
return canonicalHeader({
|
|
769
|
+
config: {
|
|
770
|
+
provider: selected.provider,
|
|
771
|
+
model: selected.model
|
|
772
|
+
},
|
|
773
|
+
...assembly.tools.length > 0 ? { tools: [...assembly.tools] } : {}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Next-request input under the target route. Uses the committed surface priced
|
|
778
|
+
* for the target (including image tokens), then adds still-pending enter
|
|
779
|
+
* messages and the prospective system prompt while dropping already-priced
|
|
780
|
+
* system nodes so they are not counted twice.
|
|
781
|
+
*
|
|
782
|
+
* The target envelope has no provider usage of its own, so the meter can only
|
|
783
|
+
* price it with the chars-per-token heuristic. That heuristic is unconservative
|
|
784
|
+
* for dense text (base64 payloads, minified sources, CJK). The previous
|
|
785
|
+
* envelope holds the provider's reported usage for the same surface, so the
|
|
786
|
+
* switch takes the larger of the two prices: the anchor keeps the check
|
|
787
|
+
* truthful exactly where a switch makes the heuristic weakest.
|
|
788
|
+
*
|
|
789
|
+
* @param meter - replay token meter.
|
|
790
|
+
* @param session - session being measured.
|
|
791
|
+
* @param header - target request envelope.
|
|
792
|
+
* @param assembly - prospective prompt assembly.
|
|
793
|
+
* @param pending - messages the pre-step is about to enter.
|
|
794
|
+
* @param previousHeader - envelope of the last request, when the session has one.
|
|
795
|
+
* @returns Estimated next-request input tokens.
|
|
796
|
+
*/
|
|
797
|
+
function estimateSwitchPressure(meter, session, header, assembly, pending, previousHeader) {
|
|
798
|
+
const estimate = routePressure(meter, session, header, assembly, pending);
|
|
799
|
+
if (previousHeader === void 0) return estimate;
|
|
800
|
+
return Math.max(estimate, routePressure(meter, session, previousHeader, assembly, pending));
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Price one request envelope against the committed surface.
|
|
804
|
+
* @param meter - replay token meter.
|
|
805
|
+
* @param session - session being measured.
|
|
806
|
+
* @param header - request envelope to price.
|
|
807
|
+
* @param assembly - prospective prompt assembly.
|
|
808
|
+
* @param pending - messages the pre-step is about to enter.
|
|
809
|
+
* @returns Estimated input tokens for that envelope.
|
|
810
|
+
*/
|
|
811
|
+
function routePressure(meter, session, header, assembly, pending) {
|
|
812
|
+
const measured = meter.measure(session, header);
|
|
813
|
+
let systemTokens = 0;
|
|
814
|
+
for (const node of measured.nodes) if (session.eventAt(node.seq)?.type === "system/message") systemTokens += node.tokens;
|
|
815
|
+
return measured.totalTokens - systemTokens + systemPromptTokens(meter, assembly) + pendingTokens(meter, pending);
|
|
816
|
+
}
|
|
817
|
+
function estimateFixedInput(meter, session, header, assembly, pending) {
|
|
818
|
+
const measured = meter.measure(session, header);
|
|
819
|
+
return measured.totalTokens - measured.surfaceTokens + systemPromptTokens(meter, assembly) + pendingTokens(meter, pending);
|
|
820
|
+
}
|
|
821
|
+
function pendingTokens(meter, messages) {
|
|
822
|
+
let total = 0;
|
|
823
|
+
for (const message of messages) {
|
|
824
|
+
const tokens = meter.estimateMessage(message);
|
|
825
|
+
if (!(tokens >= 0) || !Number.isFinite(tokens)) throw new Error("pending message token estimate is not a number");
|
|
826
|
+
total += tokens;
|
|
827
|
+
}
|
|
828
|
+
return total;
|
|
829
|
+
}
|
|
830
|
+
function systemPromptTokens(meter, assembly) {
|
|
831
|
+
const text = renderPrompt(assembly);
|
|
832
|
+
if (text.length === 0) return 0;
|
|
833
|
+
return meter.estimateMessage(createMessage({
|
|
834
|
+
role: "system",
|
|
835
|
+
content: [{
|
|
836
|
+
type: "text",
|
|
837
|
+
text
|
|
838
|
+
}],
|
|
839
|
+
source: { kind: "system-prompt" }
|
|
840
|
+
}));
|
|
841
|
+
}
|
|
842
|
+
function retainUserClaims(session, claimed) {
|
|
843
|
+
for (const message of claimed) {
|
|
844
|
+
if (message.source.kind !== "user") continue;
|
|
845
|
+
session.append("user/message", message, { surfaceOp: "append" });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async function compactWithPrevious(ctx, agent, previous, signal) {
|
|
849
|
+
const isolated = ctx.isolate("compaction");
|
|
850
|
+
const Engine = (await import("@deepseek-ai/dsh-compaction-basic")).default;
|
|
851
|
+
const fiber = isolated.plugin(Engine, {
|
|
852
|
+
auto: false,
|
|
853
|
+
summarizationProvider: previous.provider,
|
|
854
|
+
summarizationModel: previous.model,
|
|
855
|
+
compactionRetries: 0
|
|
856
|
+
});
|
|
857
|
+
try {
|
|
858
|
+
await fiber;
|
|
859
|
+
const engine = isolated.get("compaction", false);
|
|
860
|
+
if (engine === void 0) throw new Error("private compaction engine is unavailable");
|
|
861
|
+
return await engine.compactIfNeeded(agent, "context-overflow", signal) === null ? "none" : "compacted";
|
|
862
|
+
} finally {
|
|
863
|
+
await fiber.dispose();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region lib/types/host-runtime.js
|
|
868
|
+
/** Host owner for Model Switch settings and the released Main-default adapter. */
|
|
869
|
+
var ModelSwitchRuntime = class extends Service {
|
|
870
|
+
static inject = ["agentDefaultModel"];
|
|
871
|
+
static Config = Config;
|
|
872
|
+
get capabilities() {
|
|
873
|
+
const catalog = this.adapters.searchCatalog();
|
|
874
|
+
return {
|
|
875
|
+
...RUNTIME_CAPABILITIES,
|
|
876
|
+
searchProviderAdapters: {
|
|
877
|
+
available: true,
|
|
878
|
+
providers: catalog.map((provider) => provider.id),
|
|
879
|
+
catalog
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
adapters = new ModelSwitchAdapterRegistry();
|
|
884
|
+
source;
|
|
885
|
+
constructor(ctx, entry) {
|
|
886
|
+
super(ctx, "modelSwitch");
|
|
887
|
+
this.source = () => readConfig(entry);
|
|
888
|
+
ctx.inject(["settings"], (scope) => scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber), "Model Switch: disable automatic settings form"));
|
|
889
|
+
if (!allowDshRuntime(ctx.logger, "dsh-model-switch", ["@deepseek-ai/dsh-agent"])) return;
|
|
890
|
+
installSwitchCompaction(ctx, () => this.source(), (provider) => this.adapters.get(provider)?.role);
|
|
891
|
+
installDeepSeekSearchAdapter(ctx);
|
|
892
|
+
installModelSwitchSearchProvider(ctx, this);
|
|
893
|
+
installCapabilitiesRpc(ctx, this.adapters, () => this.capabilities);
|
|
894
|
+
const imageTool = installGenerateImageTool(ctx, this);
|
|
895
|
+
ctx.on("loader/volatile-update", () => {
|
|
896
|
+
imageTool.reconcile().catch((error) => {
|
|
897
|
+
ctx.logger.error("Model Switch: failed to regenerate generate_image schema");
|
|
898
|
+
ctx.logger.error(error);
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
currentSettings() {
|
|
903
|
+
return { ...this.source() };
|
|
904
|
+
}
|
|
905
|
+
currentMainSelection() {
|
|
906
|
+
return { ...this.ctx.agentDefaultModel.currentSelection() };
|
|
907
|
+
}
|
|
908
|
+
async saveMainSelection(selection) {
|
|
909
|
+
await this.ctx.agentDefaultModel.saveSelection(selection);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
function mainDefaultPort(service) {
|
|
913
|
+
return {
|
|
914
|
+
currentSelection: () => ({ ...service.currentSelection() }),
|
|
915
|
+
saveSelection: async (selection) => service.saveSelection(selection)
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const CONTEXT_SUFFIX = /-(\d+)(k|m)$/iu;
|
|
919
|
+
/** Peel Fast and `-<n>k` / `-<n>m` in either order. Product names like `-max` stay. */
|
|
920
|
+
function parsePickerId(id) {
|
|
921
|
+
let rest = id;
|
|
922
|
+
let fast = false;
|
|
923
|
+
let contextTier = null;
|
|
924
|
+
let contextTokens;
|
|
925
|
+
for (;;) {
|
|
926
|
+
if (rest.endsWith("-fast") && rest.length > 5) {
|
|
927
|
+
rest = rest.slice(0, -5);
|
|
928
|
+
fast = true;
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const match = CONTEXT_SUFFIX.exec(rest);
|
|
932
|
+
if (match !== null && match.index > 0) {
|
|
933
|
+
const n = Number(match[1]);
|
|
934
|
+
const unit = match[2].toLowerCase();
|
|
935
|
+
rest = rest.slice(0, match.index);
|
|
936
|
+
contextTier = `${n}${unit}`;
|
|
937
|
+
contextTokens = unit === "m" ? n * 1e6 : n * 1e3;
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
base: rest,
|
|
944
|
+
fast,
|
|
945
|
+
contextTier,
|
|
946
|
+
...contextTokens === void 0 ? {} : { contextTokens }
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function memberOf(model) {
|
|
950
|
+
const parsed = parsePickerId(model.id);
|
|
951
|
+
return {
|
|
952
|
+
model,
|
|
953
|
+
fast: parsed.fast,
|
|
954
|
+
contextTier: parsed.contextTier,
|
|
955
|
+
...parsed.contextTokens === void 0 ? {} : { contextTokens: parsed.contextTokens },
|
|
956
|
+
thinking: model.reasoning !== void 0
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
/** Group directory rows by provider + peeled base. */
|
|
960
|
+
function groupFamilies(groups) {
|
|
961
|
+
const families = [];
|
|
962
|
+
const index = /* @__PURE__ */ new Map();
|
|
963
|
+
for (const group of groups) for (const model of group.models) {
|
|
964
|
+
const parsed = parsePickerId(model.id);
|
|
965
|
+
const key = `${group.id}\0${parsed.base}`;
|
|
966
|
+
let family = index.get(key);
|
|
967
|
+
if (family === void 0) {
|
|
968
|
+
family = {
|
|
969
|
+
provider: group.id,
|
|
970
|
+
providerName: group.name,
|
|
971
|
+
base: parsed.base,
|
|
972
|
+
name: displayNameOf(model.name, parsed),
|
|
973
|
+
members: []
|
|
974
|
+
};
|
|
975
|
+
index.set(key, family);
|
|
976
|
+
families.push(family);
|
|
977
|
+
}
|
|
978
|
+
family.members.push(memberOf(model));
|
|
979
|
+
if (!parsed.fast && parsed.contextTier === null) family.name = displayNameOf(model.name, parsed);
|
|
980
|
+
}
|
|
981
|
+
return families;
|
|
982
|
+
}
|
|
983
|
+
function displayNameOf(name, parsed) {
|
|
984
|
+
let next = name;
|
|
985
|
+
if (parsed.fast) next = next.replace(/\s+Fast$/iu, "");
|
|
986
|
+
if (parsed.contextTier !== null) next = next.replace(/\s+(?:Max|1M)$/iu, "");
|
|
987
|
+
return next.replace(/\s+/gu, " ").trim() || name;
|
|
988
|
+
}
|
|
989
|
+
//#endregion
|
|
990
|
+
//#region lib/types/picker/plan-review.js
|
|
991
|
+
function planReviewOf(questions) {
|
|
992
|
+
if (questions.length !== 1) return void 0;
|
|
993
|
+
const question = questions[0];
|
|
994
|
+
if (question === void 0) return void 0;
|
|
995
|
+
const intent = question.intent;
|
|
996
|
+
if (intent?.kind !== "plan-review" || question.detail === void 0 || question.multiSelect === true) return void 0;
|
|
997
|
+
const options = question.options ?? [];
|
|
998
|
+
if (options.length > 2) return void 0;
|
|
999
|
+
const approve = options.find((option) => option.label === intent.approve);
|
|
1000
|
+
if (approve === void 0) return void 0;
|
|
1001
|
+
const decline = options.find((option) => option.label !== intent.approve);
|
|
1002
|
+
return {
|
|
1003
|
+
id: question.id,
|
|
1004
|
+
question: question.question,
|
|
1005
|
+
plan: question.detail,
|
|
1006
|
+
approve,
|
|
1007
|
+
...decline === void 0 ? {} : { decline }
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function selectPlanReview(owner) {
|
|
1011
|
+
const interaction = owner.pendingInteraction;
|
|
1012
|
+
if (interaction === void 0 || interaction.kind !== "question" && interaction.kind !== "plan-review") return null;
|
|
1013
|
+
return planReviewOf(interaction.questions) === void 0 ? null : interaction;
|
|
1014
|
+
}
|
|
1015
|
+
async function approvePlanReview(args) {
|
|
1016
|
+
const current = args.current;
|
|
1017
|
+
const commit = current !== void 0 && current !== null && current.provider === args.selection.provider && current.model === args.selection.model && current.reasoningEffort === args.selection.reasoningEffort ? {
|
|
1018
|
+
committed: true,
|
|
1019
|
+
mainDefaultRestored: true
|
|
1020
|
+
} : await args.select(args.selection);
|
|
1021
|
+
if (!commit.committed) return false;
|
|
1022
|
+
await args.answer();
|
|
1023
|
+
if (!commit.mainDefaultRestored) args.onNotRestored?.(args.selection.model);
|
|
1024
|
+
return true;
|
|
1025
|
+
}
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region lib/types/index.js
|
|
1028
|
+
const name = "dsh-model-switch";
|
|
1029
|
+
//#endregion
|
|
1030
|
+
export { Config, ModelSwitchAdapterRegistry, ModelSwitchRuntime, ModelSwitchRuntime as default, RUNTIME_CAPABILITIES, approvePlanReview, groupFamilies, mainDefaultPort, name, parsePickerId, planReviewOf, readConfig, selectPlanReview };
|