dsh-mobile-plugin 0.5.10 → 0.5.11

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.
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 模型能力配置(移动端):公开参数快照 + 把用户选的那份写回这台电脑的渠道。
5
+ *
6
+ * 两条路由:
7
+ * - `GET /api/mobile/v1/model-meta`(token):整份公开参数快照(App 拉一次、落盘缓存,
8
+ * 之后搜索与命中都在 App 端做,断网 / 连不上 GitHub 都不影响)。另带 `overrides`:
9
+ * `llm-pi-ai` 里每条自建路由**已经显式写下的**能力字段,弹窗用它把当前值填对。
10
+ * - `POST /api/mobile/v1/model-meta/apply`(token):把一份配置写进那条自建渠道的模型。
11
+ *
12
+ * 为什么只在插件这一侧写:渠道真源是宿主的 `llm-pi-ai` 命名空间,手机没有直接读写宿主配置的
13
+ * 通道,这一层做的与「渠道管理」保存时完全一样 —— `settings.describe` 读出那一条路由、
14
+ * 合并这一份模型的能力字段、再按路径 `settings.mutate` 写回去(只写 `providers.<id>`,
15
+ * 不碰别的路由)。内置渠道(别的插件注册的 provider)不在这份清单里,**一律 409 说明改不了**,
16
+ * 不给一个点了必然失败的保存按钮留后路。
17
+ */
18
+
19
+ const { sendJson, readJsonBody } = require('../shared/http-util');
20
+ const { unwrap } = require('./envelope.js');
21
+ const { dropSessionModelsForDevice } = require('./session-cache.js');
22
+ const modelMeta = require('./model-meta.js');
23
+
24
+ /** 自建渠道写在这个命名空间里;与 `channels.js` 的 PI_AI_NS 同一个。 */
25
+ const PI_AI_NS = 'llm-pi-ai';
26
+
27
+ const MAX_PROVIDER_CHARS = 64;
28
+ const MAX_MODEL_CHARS = 200;
29
+ /** 上下文 / 输出上限的合理上限:再大就不是 token 数了(1 千万已经远超人能用的窗口)。 */
30
+ const MAX_TOKENS = 10_000_000;
31
+
32
+ /** `llm-pi-ai` 的 providers 字典;形状不对就当空表。 */
33
+ function piAiProvidersOf(value) {
34
+ const providers = value && typeof value === 'object' && !Array.isArray(value) ? value.providers : null;
35
+ return providers && typeof providers === 'object' && !Array.isArray(providers) ? providers : {};
36
+ }
37
+
38
+ async function readPiAiProviders(ctx) {
39
+ const describe = unwrap(await ctx.apiProxy.settings.describe({
40
+ rpcId: 'mobile',
41
+ method: 'settings.describe',
42
+ payload: {},
43
+ }));
44
+ if (describe.error) return { error: describe.error };
45
+ const namespaces = Array.isArray(describe.value && describe.value.namespaces) ? describe.value.namespaces : [];
46
+ const piAi = namespaces.find((entry) => entry && entry.ns === PI_AI_NS);
47
+ return { providers: piAiProvidersOf(piAi && piAi.value) };
48
+ }
49
+
50
+ /** 一条模型上「已经显式写过」的能力字段(只有写过的才算,缺省值不算用户的选择)。 */
51
+ function capabilityOf(model) {
52
+ if (!model || typeof model !== 'object') return undefined;
53
+ const caps = {};
54
+ if (Number(model.contextWindow) > 0) caps.contextWindow = Math.trunc(Number(model.contextWindow));
55
+ if (Number(model.maxTokens) > 0) caps.maxTokens = Math.trunc(Number(model.maxTokens));
56
+ if (Array.isArray(model.input) && model.input.includes('image')) caps.vision = true;
57
+ if (Array.isArray(model.reasoningEfforts)) {
58
+ const levels = model.reasoningEfforts.filter((level) => modelMeta.MODEL_LEVELS.includes(level));
59
+ if (levels.length > 0) caps.reasoning = levels;
60
+ } else if (model.reasoningEfforts && typeof model.reasoningEfforts === 'object') {
61
+ const levels = modelMeta.MODEL_LEVELS.filter((level) => model.reasoningEfforts[level] !== undefined);
62
+ if (levels.length === 0) caps.reasoning = [];
63
+ else caps.reasoning = levels;
64
+ } else if (model.reasoningEfforts === false) {
65
+ caps.reasoning = [];
66
+ }
67
+ return Object.keys(caps).length === 0 ? undefined : caps;
68
+ }
69
+
70
+ /** 路由发下来的那份配置里,哪些渠道已经写了能力字段。 */
71
+ function overridesOf(providers) {
72
+ const out = {};
73
+ for (const providerId of Object.keys(providers)) {
74
+ const profile = providers[providerId];
75
+ const models = profile && Array.isArray(profile.models) ? profile.models : [];
76
+ for (const model of models) {
77
+ const id = model && typeof model.id === 'string' ? model.id : '';
78
+ if (id === '') continue;
79
+ const caps = capabilityOf(model);
80
+ if (caps) out[`${providerId}\u0001${id}`] = caps;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * GET /api/mobile/v1/model-meta[?q=关键词]
88
+ *
89
+ * 不带 `q` 回整份快照(App 缓存那一份,约 450KB);带 `q` 只回这一搜的结果(给不缓存的一次性
90
+ * 查询用)。两种情况都带 `revision`:内容没变时 App 不必把整份重新落盘。
91
+ */
92
+ async function handleModelMeta(ctx, req, res) {
93
+ const url = new URL(req.url, 'http://mobile.local');
94
+ const query = url.searchParams.get('q') || '';
95
+ const limit = Number(url.searchParams.get('limit'));
96
+ const providers = await readPiAiProviders(ctx);
97
+ if (providers.error) return sendJson(res, 400, { ok: false, error: providers.error });
98
+ const overrides = overridesOf(providers.providers);
99
+ const revision = modelMeta.revision;
100
+ if (query.trim() !== '') {
101
+ sendJson(res, 200, { ok: true, revision, items: modelMeta.searchModelMeta(query, limit), overrides });
102
+ return;
103
+ }
104
+ const full = modelMeta.fullModelMeta();
105
+ sendJson(res, 200, { ok: true, revision: full.revision, total: full.total, items: full.items, overrides });
106
+ }
107
+
108
+ /** 一次提交里哪些字段是「用户真的给了」—— 缺的沿用那台电脑原来的值,别拿缺省值覆盖。 */
109
+ function normalizeApply(body) {
110
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
111
+ const model = typeof body.model === 'string' ? body.model.trim() : '';
112
+ if (provider === '' || provider.length > MAX_PROVIDER_CHARS) {
113
+ return { error: { code: 'bad-request', message: '渠道 id 不合法' } };
114
+ }
115
+ if (model === '' || model.length > MAX_MODEL_CHARS) {
116
+ return { error: { code: 'bad-request', message: '模型 id 不合法' } };
117
+ }
118
+ const patch = {};
119
+ if (body.contextWindow !== undefined) {
120
+ const value = Number(body.contextWindow);
121
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_TOKENS) {
122
+ return { error: { code: 'bad-request', message: '上下文窗口必须是正整数' } };
123
+ }
124
+ patch.contextWindow = value;
125
+ }
126
+ if (body.maxTokens !== undefined) {
127
+ const value = Number(body.maxTokens);
128
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_TOKENS) {
129
+ return { error: { code: 'bad-request', message: '输出上限必须是正整数' } };
130
+ }
131
+ patch.maxTokens = value;
132
+ }
133
+ if (body.vision !== undefined) {
134
+ if (typeof body.vision !== 'boolean') return { error: { code: 'bad-request', message: '读图能力必须是布尔值' } };
135
+ patch.vision = body.vision;
136
+ }
137
+ if (body.reasoning !== undefined) {
138
+ if (!Array.isArray(body.reasoning)) return { error: { code: 'bad-request', message: '推理档位必须是数组' } };
139
+ const levels = [];
140
+ for (const level of body.reasoning) {
141
+ if (!modelMeta.MODEL_LEVELS.includes(level)) {
142
+ return { error: { code: 'bad-request', message: `推理档位不认识:${String(level)}` } };
143
+ }
144
+ if (!levels.includes(level)) levels.push(level);
145
+ }
146
+ patch.reasoning = modelMeta.MODEL_LEVELS.filter((level) => levels.includes(level));
147
+ }
148
+ if (Object.keys(patch).length === 0) {
149
+ return { error: { code: 'bad-request', message: '没有要改的字段' } };
150
+ }
151
+ const context = patch.contextWindow;
152
+ const output = patch.maxTokens;
153
+ if (context !== undefined && output !== undefined && output >= context) {
154
+ return { error: { code: 'bad-request', message: '输出上限必须小于上下文窗口' } };
155
+ }
156
+ return { provider, model, patch };
157
+ }
158
+
159
+ /** 把一份能力配置合进那条模型(其它字段原样保留)。 */
160
+ function mergeModel(model, patch) {
161
+ const merged = { ...model };
162
+ if (patch.contextWindow !== undefined) merged.contextWindow = patch.contextWindow;
163
+ if (patch.maxTokens !== undefined) merged.maxTokens = patch.maxTokens;
164
+ if (patch.vision !== undefined) merged.input = patch.vision ? ['text', 'image'] : ['text'];
165
+ if (patch.reasoning !== undefined) {
166
+ merged.reasoningEfforts = patch.reasoning.length === 0
167
+ ? false
168
+ : Object.fromEntries(patch.reasoning.map((level) => [level, level]));
169
+ }
170
+ return merged;
171
+ }
172
+
173
+ /**
174
+ * POST /api/mobile/v1/model-meta/apply(token){ provider, model, contextWindow?, maxTokens?, vision?, reasoning? }
175
+ *
176
+ * 只写 `llm-pi-ai` 里真的存在的那条渠道的**那一个模型**:读出现有 profile、换掉这一条模型、
177
+ * 按路径 `set` 回去。不认识的能力字段与渠道层字段(`api` / `baseURL` / `apiKeyEnv`)原样保留,
178
+ * 所以改一次能力不会把桌面调好的地址或别人的路由冲掉。
179
+ */
180
+ async function handleModelMetaApply(ctx, req, res) {
181
+ const body = await readJsonBody(req, 64 * 1024);
182
+ if (!body || typeof body !== 'object') {
183
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要一份模型配置' } });
184
+ }
185
+ const normalized = normalizeApply(body);
186
+ if (normalized.error) return sendJson(res, 400, { ok: false, error: normalized.error });
187
+
188
+ const read = await readPiAiProviders(ctx);
189
+ if (read.error) return sendJson(res, 400, { ok: false, error: read.error });
190
+ const providers = read.providers;
191
+ const profile = providers[normalized.provider];
192
+ if (!profile) {
193
+ // 不是自建渠道:别的插件注册的 provider,手机这一侧没有归它管的配置。
194
+ return sendJson(res, 409, {
195
+ ok: false,
196
+ error: { code: 'model-meta-external', message: '这条渠道不归本机配置,改不了它的模型参数' },
197
+ });
198
+ }
199
+ const models = Array.isArray(profile.models) ? profile.models : [];
200
+ const index = models.findIndex((model) => model && model.id === normalized.model);
201
+ if (index < 0) {
202
+ return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这条渠道里没有这个模型' } });
203
+ }
204
+ const current = models[index];
205
+ const context = normalized.patch.contextWindow !== undefined ? normalized.patch.contextWindow : Number(current.contextWindow);
206
+ const output = normalized.patch.maxTokens !== undefined ? normalized.patch.maxTokens : Number(current.maxTokens);
207
+ if (Number(context) > 0 && Number(output) > 0 && output >= context) {
208
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '输出上限必须小于上下文窗口' } });
209
+ }
210
+
211
+ const nextModels = models.slice();
212
+ nextModels[index] = mergeModel(current, normalized.patch);
213
+ const write = unwrap(await ctx.apiProxy.settings.mutate({
214
+ rpcId: 'mobile',
215
+ method: 'settings.mutate',
216
+ payload: { ns: PI_AI_NS, ops: [{ op: 'set', path: ['providers', normalized.provider], value: { ...profile, models: nextModels } }] },
217
+ }));
218
+ if (write.error) return sendJson(res, 400, { ok: false, error: write.error });
219
+
220
+ // 模型目录(含能力字段投影)是设备级缓存、却按会话存:整批作废,别让别的会话继续吃旧值。
221
+ dropSessionModelsForDevice(ctx, req);
222
+ sendJson(res, 200, {
223
+ ok: true,
224
+ provider: normalized.provider,
225
+ model: normalized.model,
226
+ capability: capabilityOf(nextModels[index]) || {},
227
+ });
228
+ }
229
+
230
+ module.exports = {
231
+ handleModelMeta,
232
+ handleModelMetaApply,
233
+ normalizeApply,
234
+ mergeModel,
235
+ capabilityOf,
236
+ overridesOf,
237
+ PI_AI_NS,
238
+ MAX_TOKENS,
239
+ };