dsh-model-input-toggle 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,761 @@
1
+ /**
2
+ * dsh-model-input-toggle — host-half integration test.
3
+ *
4
+ * WHAT THIS PROVES
5
+ * ----------------
6
+ * It mounts the REAL `apply()` from lib/index.js against fake `settings`,
7
+ * `llm`, `webServer` and `webRuntime` services, then drives the real HTTP
8
+ * handler. The fake settings service implements the shipped `applyPathOp` /
9
+ * `mergeLayers` semantics (see ./settings-semantics.mjs), so a wrong path or a
10
+ * mis-shaped op fails here exactly as it would against the real service.
11
+ *
12
+ * Covered: inventory classification, both pi-ai write branches, DeepSeek write,
13
+ * conflict retry, the error taxonomy, the trust fence, and the endpoint probe.
14
+ *
15
+ * Run: node tools/test-host.mjs
16
+ */
17
+
18
+ import { EventEmitter } from "node:events";
19
+ import { assert, assertDeepEqual, applyPathOps, mergeLayers } from "./settings-semantics.mjs";
20
+
21
+ const plugin = await import("../lib/index.js");
22
+
23
+ // ── test harness ───────────────────────────────────────────────────────────
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+
28
+ /** Run one named async test, reporting pass/fail without stopping the suite. */
29
+ async function test(name, fn) {
30
+ try {
31
+ await fn();
32
+ passed += 1;
33
+ process.stdout.write(` ok ${name}\n`);
34
+ } catch (error) {
35
+ failed += 1;
36
+ process.stdout.write(` FAIL ${name}\n ${String(error?.message ?? error).replace(/\n/g, "\n ")}\n`);
37
+ }
38
+ }
39
+
40
+ // ── fake services ──────────────────────────────────────────────────────────
41
+
42
+ const SETTINGS_CONFLICT = Symbol("conflict");
43
+
44
+ /** Settings service stub with the shipped conflict + layering semantics. */
45
+ function makeSettings(sections, options = {}) {
46
+ const state = new Map();
47
+ for (const [ns, section] of Object.entries(sections)) {
48
+ state.set(ns, { user: section, revision: 0, base: options.bases?.[ns] ?? {}, resolve: options.resolvers?.[ns] ?? ((u) => u) });
49
+ }
50
+ let conflictsLeft = options.conflictsLeft ?? 0;
51
+ const writes = [];
52
+ return {
53
+ writes,
54
+ /** Test-visible current user layer for one namespace. */
55
+ userOf(ns) {
56
+ return state.get(ns)?.user;
57
+ },
58
+ /** Test-visible resolved value for one namespace. */
59
+ valueOf(ns) {
60
+ const entry = state.get(ns);
61
+ return entry === undefined ? undefined : entry.resolve(entry.user);
62
+ },
63
+ describe() {
64
+ const out = [];
65
+ for (const [ns, entry] of state) {
66
+ out.push({
67
+ ns,
68
+ revision: entry.revision,
69
+ value: entry.resolve(entry.user),
70
+ user: entry.user,
71
+ base: entry.base,
72
+ applies: "live"
73
+ });
74
+ }
75
+ return out;
76
+ },
77
+ async mutate(ns, ops, expectedRevision) {
78
+ const entry = state.get(ns);
79
+ if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`);
80
+ if (entry.revision !== expectedRevision) {
81
+ const error = new Error(`settings namespace "${ns}" changed since it was read`);
82
+ error.code = "SETTINGS_CONFLICT";
83
+ error.expected = expectedRevision;
84
+ error.actual = entry.revision;
85
+ throw error;
86
+ }
87
+ if (conflictsLeft > 0) {
88
+ conflictsLeft -= 1;
89
+ entry.revision += 1; // move it so the retry's read is fresh
90
+ const error = new Error(`settings namespace "${ns}" changed since it was read`);
91
+ error.code = "SETTINGS_CONFLICT";
92
+ throw error;
93
+ }
94
+ entry.user = applyPathOps(entry.user, ops);
95
+ entry.revision += 1;
96
+ writes.push({ ns, ops });
97
+ },
98
+ section(ns) {
99
+ return state.get(ns)?.user;
100
+ },
101
+ /** Exposed so a test can model the conflict symbol. */
102
+ conflict: SETTINGS_CONFLICT
103
+ };
104
+ }
105
+
106
+ /**
107
+ * A faithful-enough model of what llm-pi-ai resolves for the fields this plugin
108
+ * reads: `defaultInput`, per-model `input` (user -> catalog -> default), plus
109
+ * the route's display/transport facts.
110
+ */
111
+ function resolvePiAi(user, catalog) {
112
+ const providers = {};
113
+ const userProviders = user.providers ?? {};
114
+ for (const [route, configured] of Object.entries(userProviders)) {
115
+ const known = catalog[route];
116
+ const catalogBacked = known !== undefined && configured.catalogBacked !== true;
117
+ const defaultInput = Array.isArray(configured.defaultInput)
118
+ ? configured.defaultInput
119
+ : Array.isArray(known?.defaultInput)
120
+ ? known.defaultInput
121
+ : ["text"];
122
+ const models = (Array.isArray(configured.models) ? configured.models : []).map((model) => {
123
+ const fromCatalog = known?.models?.find((candidate) => candidate.id === model.id);
124
+ return {
125
+ ...model,
126
+ name: model.name ?? fromCatalog?.name ?? model.id,
127
+ input: model.input ?? fromCatalog?.input ?? defaultInput
128
+ };
129
+ });
130
+ providers[route] = {
131
+ displayName: configured.displayName ?? route,
132
+ baseURL: configured.baseURL,
133
+ api: configured.api ?? "openai-completions",
134
+ apiKeyEnv: configured.apiKeyEnv,
135
+ defaultInput,
136
+ models,
137
+ catalogBacked
138
+ };
139
+ }
140
+ // Routes the installed catalog serves and the user has not declared.
141
+ for (const [route, known] of Object.entries(catalog)) {
142
+ if (providers[route] !== undefined) continue;
143
+ providers[route] = {
144
+ displayName: route,
145
+ baseURL: known.baseURL,
146
+ api: "openai-completions",
147
+ defaultInput: known.defaultInput ?? ["text"],
148
+ models: (known.models ?? []).map((model) => ({
149
+ ...model,
150
+ name: model.name ?? model.id,
151
+ input: model.input ?? (known.defaultInput ?? ["text"])
152
+ })),
153
+ catalogBacked: true
154
+ };
155
+ }
156
+ return { providers };
157
+ }
158
+
159
+ /** llm stub: a configurable-provider directory plus resolveModelInfo. */
160
+ function makeLlm(settings, catalog) {
161
+ const resolvePi = () => resolvePiAi(settings.userOf("llm-pi-ai") ?? {}, catalog);
162
+ return {
163
+ listConfigurableProviders() {
164
+ const resolved = resolvePi();
165
+ return Object.entries(resolved.providers).map(([route, profile]) => ({
166
+ provider: route,
167
+ displayName: profile.displayName,
168
+ settingsNs: "llm-pi-ai",
169
+ settingsPath: ["providers", route],
170
+ declared: profile.catalogBacked !== true
171
+ }));
172
+ },
173
+ async resolveModelInfo(provider, model) {
174
+ if (provider === "deepseek-official") {
175
+ const ds = settings.userOf("llm-deepseek") ?? {};
176
+ const entry = (ds.models ?? []).find((candidate) => candidate.id === model);
177
+ return { provider, id: model, name: model, inputModalities: entry?.inputModalities ?? ["text"] };
178
+ }
179
+ const resolved = resolvePi().providers[provider];
180
+ if (resolved === undefined) throw new Error(`no adapter for provider "${provider}"`);
181
+ const entry = resolved.models.find((candidate) => candidate.id === model);
182
+ if (entry === undefined) throw new Error(`unknown model "${model}"`);
183
+ return { provider, id: model, name: entry.name, inputModalities: [...entry.input] };
184
+ }
185
+ };
186
+ }
187
+
188
+ /** Mount the plugin against fake services and return the route handler. */
189
+ function mount(settings, llm) {
190
+ const routes = [];
191
+ const ctx = {
192
+ get(name) {
193
+ if (name === "settings") return settings;
194
+ if (name === "llm") return llm;
195
+ return undefined;
196
+ },
197
+ effect(callback, label) {
198
+ const disposer = callback();
199
+ return disposer ?? (() => void label);
200
+ },
201
+ webServer: {
202
+ register(route) {
203
+ routes.push(route);
204
+ return () => void 0;
205
+ }
206
+ },
207
+ webRuntime: { trustedHosts: [] }
208
+ };
209
+ plugin.apply(ctx);
210
+ assert(routes.length === 1, "exactly one route should be registered");
211
+ assert(routes[0].kind === "prefix", "the route should be a prefix route");
212
+ return routes[0].handler;
213
+ }
214
+
215
+ /** Build a fake request carrying a JSON body. */
216
+ function makeReq(body, headers = {}) {
217
+ const emitter = new EventEmitter();
218
+ emitter.method = "POST";
219
+ emitter.headers = {
220
+ host: "127.0.0.1:3080",
221
+ "content-type": "application/json",
222
+ ...headers
223
+ };
224
+ emitter.destroy = () => void 0;
225
+ process.nextTick(() => {
226
+ emitter.emit("data", Buffer.from(JSON.stringify(body)));
227
+ emitter.emit("end");
228
+ });
229
+ return emitter;
230
+ }
231
+
232
+ /** Invoke the handler and decode the JSON reply. */
233
+ function call(handler, body, headers) {
234
+ return new Promise((resolve, reject) => {
235
+ const res = {
236
+ status: 0,
237
+ body: undefined,
238
+ writeHead(status) {
239
+ this.status = status;
240
+ },
241
+ end(text) {
242
+ try {
243
+ this.body = JSON.parse(text);
244
+ } catch (error) {
245
+ reject(error);
246
+ return;
247
+ }
248
+ resolve({ status: this.status, body: this.body });
249
+ }
250
+ };
251
+ handler(makeReq(body, headers), res).catch(reject);
252
+ });
253
+ }
254
+
255
+ // ── fixtures ───────────────────────────────────────────────────────────────
256
+
257
+ /** The user's own providers: two models, one with an explicit contextWindow. */
258
+ function piAiFixture(extra = {}) {
259
+ return {
260
+ providers: {
261
+ agnes: {
262
+ apiKeyEnv: "AGNES_API_KEY",
263
+ api: "openai-completions",
264
+ baseURL: "https://apihub.agnes-ai.cn/v1",
265
+ models: [{ id: "agnes-3.0-flash", contextWindow: 512000 }, { id: "agnes-2.5-flash" }],
266
+ defaultInput: ["text"]
267
+ },
268
+ ...extra
269
+ }
270
+ };
271
+ }
272
+
273
+ // A catalog-backed route: the user never declared it, so llm-pi-ai serves it
274
+ // straight from its installed catalog. Its id must NOT collide with the
275
+ // llm-deepseek provider id below, or `resolveModelInfo` (which routes by
276
+ // provider id) could not tell the two adapters apart.
277
+ const CATALOG = {
278
+ openrouter: {
279
+ baseURL: "https://openrouter.ai/api/v1",
280
+ defaultInput: ["text"],
281
+ models: [
282
+ { id: "acme-vision-1", input: ["text", "image"] },
283
+ { id: "acme-text-1", input: ["text"] }
284
+ ]
285
+ }
286
+ };
287
+
288
+ /** Resolver pair for the standard fixture. */
289
+ function standard(piUser, dsUser = {}, catalog = CATALOG) {
290
+ const settings = makeSettings(
291
+ { "llm-pi-ai": piUser, "llm-deepseek": dsUser },
292
+ { resolvers: { "llm-pi-ai": (user) => resolvePiAi(user, catalog) } }
293
+ );
294
+ const llm = makeLlm(settings, catalog);
295
+ return { settings, llm };
296
+ }
297
+
298
+ // ── tests ──────────────────────────────────────────────────────────────────
299
+
300
+ process.stdout.write("dsh-model-input-toggle host tests\n");
301
+
302
+ await test("list: reports each model's effective modalities and source", async () => {
303
+ const { settings, llm } = standard(piAiFixture());
304
+ const handler = mount(settings, llm);
305
+ const reply = await call(handler, { method: "list" });
306
+ assert(reply.status === 200, "200");
307
+ assert(reply.body.ok === true, "ok");
308
+ const agnes = reply.body.providers.find((entry) => entry.provider === "agnes");
309
+ assert(agnes !== undefined, "agnes should be listed");
310
+ assertDeepEqual(agnes.namespace, "llm-pi-ai", "namespace");
311
+ assertDeepEqual(
312
+ agnes.models.map((model) => model.id),
313
+ ["agnes-3.0-flash", "agnes-2.5-flash"],
314
+ "model ids"
315
+ );
316
+ // defaultInput is ["text"], so no model declares image.
317
+ assertDeepEqual(agnes.models[0].effective, ["text"], "effective modalities");
318
+ assert(agnes.models[0].locked === false, "a user route is not locked");
319
+ assertDeepEqual(agnes.models[0].source, "defaultInput", "source");
320
+ });
321
+
322
+ await test("list: a catalog-declared vision model is checked and locked", async () => {
323
+ const { settings, llm } = standard(piAiFixture());
324
+ const handler = mount(settings, llm);
325
+ const reply = await call(handler, { method: "list" });
326
+ const official = reply.body.providers.find((entry) => entry.provider === "openrouter" && entry.namespace === "llm-pi-ai");
327
+ assert(official !== undefined, "the catalog route should be listed");
328
+ const vision = official.models.find((model) => model.id === "acme-vision-1");
329
+ assert(vision !== undefined, "the vision model should be listed");
330
+ assertDeepEqual(vision.effective, ["text", "image"], "catalog image declaration");
331
+ assert(vision.locked === true, "a catalog declaration is locked");
332
+ assertDeepEqual(vision.source, "catalog", "source");
333
+ });
334
+
335
+ await test("list: the llm-deepseek namespace is listed separately", async () => {
336
+ const { settings, llm } = standard(piAiFixture(), {
337
+ models: [
338
+ { id: "deepseek-flash", inputModalities: ["text", "image"] },
339
+ { id: "deepseek-v4-flash", inputModalities: ["text"] }
340
+ ]
341
+ });
342
+ const handler = mount(settings, llm);
343
+ const reply = await call(handler, { method: "list" });
344
+ const ds = reply.body.providers.find((entry) => entry.namespace === "llm-deepseek");
345
+ assert(ds !== undefined, "the deepseek provider should be listed");
346
+ assertDeepEqual(ds.provider, "deepseek-official", "provider id");
347
+ assertDeepEqual(ds.models.map((model) => model.effective), [["text", "image"], ["text"]], "modalities");
348
+ });
349
+
350
+ await test("set: writes input onto the user's models entry, preserving every sibling", async () => {
351
+ const { settings, llm } = standard(piAiFixture());
352
+ const handler = mount(settings, llm);
353
+ const reply = await call(handler, {
354
+ method: "set",
355
+ namespace: "llm-pi-ai",
356
+ provider: "agnes",
357
+ modelId: "agnes-3.0-flash",
358
+ supportsImage: true
359
+ });
360
+ assert(reply.body.ok === true, `expected ok, got ${JSON.stringify(reply.body)}`);
361
+ assertDeepEqual(reply.body.declared, ["text", "image"], "verified declaration");
362
+ assert(reply.body.verified === true, "the adapter must confirm the change");
363
+
364
+ // The op must target the whole array (array indices are unreachable).
365
+ const write = settings.writes.at(-1);
366
+ assertDeepEqual(write.ops[0].path, ["providers", "agnes", "models"], "op path");
367
+ const models = settings.userOf("llm-pi-ai").providers.agnes.models;
368
+ // The edited row carries input AND its original contextWindow.
369
+ assertDeepEqual(models[0], { id: "agnes-3.0-flash", contextWindow: 512000, input: ["text", "image"] }, "edited row");
370
+ // The untouched sibling is byte-identical.
371
+ assertDeepEqual(models[1], { id: "agnes-2.5-flash" }, "sibling row");
372
+ // defaultInput is not clobbered by an entry-level write.
373
+ assertDeepEqual(settings.userOf("llm-pi-ai").providers.agnes.defaultInput, ["text"], "defaultInput preserved");
374
+ });
375
+
376
+ await test("set: disabling writes [\"text\"] back", async () => {
377
+ const fixture = piAiFixture();
378
+ fixture.providers.agnes.models[0].input = ["text", "image"];
379
+ const { settings, llm } = standard(fixture);
380
+ const handler = mount(settings, llm);
381
+ const reply = await call(handler, {
382
+ method: "set",
383
+ namespace: "llm-pi-ai",
384
+ provider: "agnes",
385
+ modelId: "agnes-3.0-flash",
386
+ supportsImage: false
387
+ });
388
+ assert(reply.body.ok === true, "ok");
389
+ assertDeepEqual(settings.userOf("llm-pi-ai").providers.agnes.models[0].input, ["text"], "written input");
390
+ assert(reply.body.verified === true, "verification follows the write down too");
391
+ });
392
+
393
+ await test("set: with no user models list, writes modelOverrides instead", async () => {
394
+ const { settings, llm } = standard({
395
+ providers: {
396
+ plain: { api: "openai-completions", baseURL: "https://example.test/v1", defaultInput: ["text"] }
397
+ }
398
+ });
399
+ const handler = mount(settings, llm);
400
+ const reply = await call(handler, {
401
+ method: "set",
402
+ namespace: "llm-pi-ai",
403
+ provider: "plain",
404
+ modelId: "some-vision-model",
405
+ supportsImage: true
406
+ });
407
+ assert(reply.body.ok === true, `expected ok, got ${JSON.stringify(reply.body)}`);
408
+ const write = settings.writes.at(-1);
409
+ assertDeepEqual(
410
+ write.ops[0].path,
411
+ ["providers", "plain", "modelOverrides", "some-vision-model", "input"],
412
+ "op path"
413
+ );
414
+ // The op must never materialize a `models` list: llm-pi-ai rejects
415
+ // modelOverrides beside a models list.
416
+ assert(settings.userOf("llm-pi-ai").providers.plain.models === undefined, "no models list is created");
417
+ assertDeepEqual(
418
+ settings.userOf("llm-pi-ai").providers.plain.modelOverrides["some-vision-model"].input,
419
+ ["text", "image"],
420
+ "override input"
421
+ );
422
+ });
423
+
424
+ await test("set: a user models list keeps the override branch unreachable", async () => {
425
+ const { settings, llm } = standard(piAiFixture());
426
+ const handler = mount(settings, llm);
427
+ await call(handler, { method: "set", namespace: "llm-pi-ai", provider: "agnes", modelId: "agnes-2.5-flash", supportsImage: true });
428
+ const user = settings.userOf("llm-pi-ai").providers.agnes;
429
+ assert(user.modelOverrides === undefined, "modelOverrides must not be introduced beside a models list");
430
+ });
431
+
432
+ await test("set: unknown provider fails cleanly", async () => {
433
+ const { settings, llm } = standard(piAiFixture());
434
+ const handler = mount(settings, llm);
435
+ const reply = await call(handler, { method: "set", namespace: "llm-pi-ai", provider: "ghost", modelId: "x", supportsImage: true });
436
+ assert(reply.body.ok === false, "must fail");
437
+ assertDeepEqual(reply.body.code, "provider-missing", "code");
438
+ assert(settings.writes.length === 0, "nothing may be written");
439
+ });
440
+
441
+ await test("set: unknown model in a user models list fails cleanly", async () => {
442
+ const { settings, llm } = standard(piAiFixture());
443
+ const handler = mount(settings, llm);
444
+ const reply = await call(handler, { method: "set", namespace: "llm-pi-ai", provider: "agnes", modelId: "ghost", supportsImage: true });
445
+ assert(reply.body.ok === false, "must fail");
446
+ assertDeepEqual(reply.body.code, "model-missing", "code");
447
+ assert(settings.writes.length === 0, "nothing may be written");
448
+ });
449
+
450
+ await test("set: an unsupported namespace is refused", async () => {
451
+ const { settings, llm } = standard(piAiFixture());
452
+ const handler = mount(settings, llm);
453
+ const reply = await call(handler, { method: "set", namespace: "llm-other", provider: "agnes", modelId: "x", supportsImage: true });
454
+ assertDeepEqual(reply.body.code, "unsupported-namespace", "code");
455
+ });
456
+
457
+ await test("set: a conflict is retried once and then succeeds", async () => {
458
+ const fixture = piAiFixture();
459
+ const settings = makeSettings(
460
+ { "llm-pi-ai": fixture, "llm-deepseek": {} },
461
+ { conflictsLeft: 1, resolvers: { "llm-pi-ai": (user) => resolvePiAi(user, CATALOG) } }
462
+ );
463
+ const llm = makeLlm(settings, CATALOG);
464
+ const handler = mount(settings, llm);
465
+ const reply = await call(handler, {
466
+ method: "set",
467
+ namespace: "llm-pi-ai",
468
+ provider: "agnes",
469
+ modelId: "agnes-3.0-flash",
470
+ supportsImage: true
471
+ });
472
+ assert(reply.body.ok === true, `the retry should succeed: ${JSON.stringify(reply.body)}`);
473
+ assertDeepEqual(settings.userOf("llm-pi-ai").providers.agnes.models[0].input, ["text", "image"], "written after retry");
474
+ });
475
+
476
+ await test("set: a persistent conflict reports a manual-refresh instruction", async () => {
477
+ const settings = makeSettings(
478
+ { "llm-pi-ai": piAiFixture(), "llm-deepseek": {} },
479
+ { conflictsLeft: 5, resolvers: { "llm-pi-ai": (user) => resolvePiAi(user, CATALOG) } }
480
+ );
481
+ const llm = makeLlm(settings, CATALOG);
482
+ const handler = mount(settings, llm);
483
+ const reply = await call(handler, {
484
+ method: "set",
485
+ namespace: "llm-pi-ai",
486
+ provider: "agnes",
487
+ modelId: "agnes-3.0-flash",
488
+ supportsImage: true
489
+ });
490
+ assertDeepEqual(reply.body.code, "conflict", "code");
491
+ assert(reply.body.message.includes("手动刷新"), "the message must tell the user what to do");
492
+ });
493
+
494
+ await test("set: writes the deepseek inputModalities field", async () => {
495
+ const { settings, llm } = standard(piAiFixture(), {
496
+ models: [{ id: "deepseek-v4-flash", inputModalities: ["text"] }]
497
+ });
498
+ const handler = mount(settings, llm);
499
+ const reply = await call(handler, {
500
+ method: "set",
501
+ namespace: "llm-deepseek",
502
+ provider: "deepseek-official",
503
+ modelId: "deepseek-v4-flash",
504
+ supportsImage: true
505
+ });
506
+ assert(reply.body.ok === true, `expected ok, got ${JSON.stringify(reply.body)}`);
507
+ assertDeepEqual(reply.body.declared, ["text", "image"], "declared");
508
+ const models = settings.userOf("llm-deepseek").models;
509
+ assertDeepEqual(models[0].inputModalities, ["text", "image"], "inputModalities written");
510
+ // The pi-ai namespace must be untouched.
511
+ assertDeepEqual(settings.userOf("llm-pi-ai"), piAiFixture(), "pi-ai untouched");
512
+ });
513
+
514
+ await test("resolved layering: the user's array replaces the base array wholesale", async () => {
515
+ // Proves the op lands in the USER layer with the semantics mergeLayers gives
516
+ // arrays: a lower layer cannot resurrect the old value, because a
517
+ // non-plain-object (an array) replaces rather than merges.
518
+ const { settings, llm } = standard(piAiFixture());
519
+ const handler = mount(settings, llm);
520
+ await call(handler, { method: "set", namespace: "llm-pi-ai", provider: "agnes", modelId: "agnes-2.5-flash", supportsImage: true });
521
+ const base = { providers: { agnes: { models: [{ id: "agnes-2.5-flash", input: ["text"] }] } } };
522
+ const merged = mergeLayers(base, settings.userOf("llm-pi-ai"));
523
+ // The user array wins entirely, so index 0 is the user's *first* model, not
524
+ // the base's — and the edited model sits at index 1.
525
+ assertDeepEqual(
526
+ merged.providers.agnes.models.map((model) => model.id),
527
+ ["agnes-3.0-flash", "agnes-2.5-flash"],
528
+ "the user array replaced the base array"
529
+ );
530
+ assertDeepEqual(merged.providers.agnes.models[1].input, ["text", "image"], "the edited entry keeps its new declaration");
531
+ });
532
+
533
+ await test("trust fence: a cross-site request is refused", async () => {
534
+ const { settings, llm } = standard(piAiFixture());
535
+ const handler = mount(settings, llm);
536
+ const reply = await call(handler, { method: "list" }, { "sec-fetch-site": "cross-site" });
537
+ assert(reply.status === 403, "403");
538
+ assertDeepEqual(reply.body.code, "forbidden", "code");
539
+ });
540
+
541
+ await test("trust fence: a foreign Host header is refused", async () => {
542
+ const { settings, llm } = standard(piAiFixture());
543
+ const handler = mount(settings, llm);
544
+ const reply = await call(handler, { method: "list" }, { host: "evil.example.com" });
545
+ assert(reply.status === 403, "403");
546
+ });
547
+
548
+ await test("protocol: GET and a wrong content-type are refused", async () => {
549
+ const { settings, llm } = standard(piAiFixture());
550
+ const handler = mount(settings, llm);
551
+ const emitter = new EventEmitter();
552
+ emitter.method = "GET";
553
+ emitter.headers = { host: "127.0.0.1:3080", "content-type": "application/json" };
554
+ emitter.destroy = () => void 0;
555
+ const getReply = await new Promise((resolve) => {
556
+ handler(emitter, { writeHead(s) { this.status = s; }, end(t) { resolve({ status: this.status, body: JSON.parse(t) }); } });
557
+ });
558
+ assert(getReply.status === 405, "405 for GET");
559
+ const wrongType = await call(handler, { method: "list" }, { "content-type": "text/plain" });
560
+ assert(wrongType.status === 415, "415 for a non-JSON content-type");
561
+ });
562
+
563
+ await test("protocol: an unknown method is a clean 404", async () => {
564
+ const { settings, llm } = standard(piAiFixture());
565
+ const handler = mount(settings, llm);
566
+ const reply = await call(handler, { method: "nope" });
567
+ assert(reply.status === 404, "404");
568
+ assertDeepEqual(reply.body.code, "not-found", "code");
569
+ });
570
+
571
+ // ── endpoint verification (mocked fetch) ───────────────────────────────────
572
+
573
+ /** Run one verify call with a scripted fetch response. */
574
+ async function withFetch(script, fn) {
575
+ const original = globalThis.fetch;
576
+ // `script` describes one response, or `script.responses` a sequence — the
577
+ // verifier deliberately sends a second, image-free control request, so any
578
+ // test exercising a non-200 path must script both.
579
+ const queue = Array.isArray(script.responses) ? script.responses.slice() : null;
580
+ const next = () => (queue === null ? script : (queue.shift() ?? { status: 500, text: "{}" }));
581
+ script.calls = [];
582
+ globalThis.fetch = async (url, init) => {
583
+ const step = next();
584
+ script.lastUrl = url;
585
+ script.lastBody = JSON.parse(init.body);
586
+ script.lastHeaders = init.headers;
587
+ script.calls.push({ url, body: script.lastBody });
588
+ if (step.throws !== undefined) throw step.throws;
589
+ return {
590
+ ok: step.status >= 200 && step.status < 300,
591
+ status: step.status,
592
+ async text() {
593
+ return step.text ?? "";
594
+ }
595
+ };
596
+ };
597
+ try {
598
+ return await fn();
599
+ } finally {
600
+ globalThis.fetch = original;
601
+ }
602
+ }
603
+
604
+ await test("verify: HTTP 200 is reported as supported", async () => {
605
+ const fixture = piAiFixture();
606
+ fixture.providers.agnes.apiKeyEnv = "TEST_KEY";
607
+ const { settings, llm } = standard(fixture);
608
+ const handler = mount(settings, llm);
609
+ process.env.TEST_KEY = "sk-test";
610
+ const script = { status: 200, text: "{}" };
611
+ const reply = await withFetch(script, () => call(handler, {
612
+ method: "verify",
613
+ namespace: "llm-pi-ai",
614
+ provider: "agnes",
615
+ modelId: "agnes-3.0-flash"
616
+ }));
617
+ assertDeepEqual(reply.body.verdict, "supported", "verdict");
618
+ assert(script.lastUrl === "https://apihub.agnes-ai.cn/v1/chat/completions", `url: ${script.lastUrl}`);
619
+ assertDeepEqual(script.lastHeaders.authorization, "Bearer sk-test", "authorization");
620
+ // The probe must be a real image-bearing OpenAI payload.
621
+ const parts = script.lastBody.messages[1].content;
622
+ assertDeepEqual(parts[1].type, "image_url", "image part");
623
+ assert(parts[1].image_url.url.startsWith("data:image/png;base64,"), "inline data URI");
624
+ // The probe leads with a system message: gateways such as this profile's
625
+ // jiuling/CodeBuddy proxy reject a request whose first message is not one,
626
+ // which would otherwise be misread as "does not support images".
627
+ assertDeepEqual(script.lastBody.messages[0].role, "system", "the probe leads with a system message");
628
+ // A 200 needs no control request: the image was demonstrably accepted.
629
+ assert(script.calls.length === 1, "a success sends exactly one request");
630
+ delete process.env.TEST_KEY;
631
+ });
632
+
633
+ await test("verify: an image-only rejection is reported as rejected", async () => {
634
+ const fixture = piAiFixture();
635
+ fixture.providers.agnes.apiKeyEnv = "TEST_KEY";
636
+ const { settings, llm } = standard(fixture);
637
+ const handler = mount(settings, llm);
638
+ process.env.TEST_KEY = "sk-test";
639
+ // The image request is refused, but the same request without the image
640
+ // succeeds — that difference is what proves the image is the problem.
641
+ const script = {
642
+ responses: [
643
+ { status: 400, text: JSON.stringify({ error: { message: "unexpected content type" } }) },
644
+ { status: 200, text: "{}" }
645
+ ]
646
+ };
647
+ const reply = await withFetch(script, () => call(handler, {
648
+ method: "verify",
649
+ namespace: "llm-pi-ai",
650
+ provider: "agnes",
651
+ modelId: "agnes-3.0-flash"
652
+ }));
653
+ assertDeepEqual(reply.body.verdict, "rejected", "verdict");
654
+ assert(reply.body.message.includes("unexpected content type"), "the endpoint's own words are surfaced");
655
+ // The control request must genuinely differ: it carries no image part.
656
+ assert(script.calls.length === 2, "a rejection is confirmed by a control request");
657
+ const controlParts = script.calls[1].body.messages[1].content;
658
+ assert(typeof controlParts === "string", "the control request drops the image parts entirely");
659
+ delete process.env.TEST_KEY;
660
+ });
661
+
662
+ await test("verify: a failure that also hits the image-free request is inconclusive", async () => {
663
+ const fixture = piAiFixture();
664
+ fixture.providers.agnes.apiKeyEnv = "TEST_KEY";
665
+ const { settings, llm } = standard(fixture);
666
+ const handler = mount(settings, llm);
667
+ process.env.TEST_KEY = "sk-test";
668
+ // Both requests fail identically — a gateway/availability problem, not an
669
+ // image problem. Accusing the model of lacking vision here would be wrong.
670
+ const script = {
671
+ responses: [
672
+ {
673
+ status: 502,
674
+ text: JSON.stringify({
675
+ error: { message: 'CodeBuddy 上游 HTTP 400:{"code":11102,"msg":"model [glm-5.3-flash] service info not found"}' }
676
+ })
677
+ },
678
+ { status: 502, text: JSON.stringify({ error: { message: "same failure" } }) }
679
+ ]
680
+ };
681
+ const reply = await withFetch(script, () => call(handler, {
682
+ method: "verify",
683
+ namespace: "llm-pi-ai",
684
+ provider: "agnes",
685
+ modelId: "agnes-3.0-flash"
686
+ }));
687
+ assertDeepEqual(reply.body.verdict, "inconclusive", "verdict");
688
+ assertDeepEqual(reply.body.ok, false, "an inconclusive probe is not a success");
689
+ assert(reply.body.message.includes("与图片无关"), `the message must not blame the image: ${reply.body.message}`);
690
+ // The wrapped JSON must be unwrapped to the one useful sentence.
691
+ assert(
692
+ reply.body.message.includes("model [glm-5.3-flash] service info not found"),
693
+ "the nested upstream message is surfaced"
694
+ );
695
+ assert(!reply.body.message.includes('\\"code\\"'), "the raw JSON envelope is not dumped");
696
+ delete process.env.TEST_KEY;
697
+ });
698
+
699
+ await test("verify: 401 is unauthorized, 404 is not-found", async () => {
700
+ const fixture = piAiFixture();
701
+ fixture.providers.agnes.apiKeyEnv = "TEST_KEY";
702
+ const { settings, llm } = standard(fixture);
703
+ const handler = mount(settings, llm);
704
+ process.env.TEST_KEY = "sk-test";
705
+ const unauthorized = await withFetch({ status: 401, text: "{}" }, () => call(handler, {
706
+ method: "verify",
707
+ namespace: "llm-pi-ai",
708
+ provider: "agnes",
709
+ modelId: "agnes-3.0-flash"
710
+ }));
711
+ assertDeepEqual(unauthorized.body.verdict, "unauthorized", "401 verdict");
712
+ const missing = await withFetch({ status: 404, text: "{}" }, () => call(handler, {
713
+ method: "verify",
714
+ namespace: "llm-pi-ai",
715
+ provider: "agnes",
716
+ modelId: "agnes-3.0-flash"
717
+ }));
718
+ assertDeepEqual(missing.body.verdict, "not-found", "404 verdict");
719
+ delete process.env.TEST_KEY;
720
+ });
721
+
722
+ await test("verify: a network failure is reported, not thrown", async () => {
723
+ const fixture = piAiFixture();
724
+ fixture.providers.agnes.apiKeyEnv = "TEST_KEY";
725
+ const { settings, llm } = standard(fixture);
726
+ const handler = mount(settings, llm);
727
+ process.env.TEST_KEY = "sk-test";
728
+ const reply = await withFetch({ throws: new Error("connect ECONNREFUSED") }, () => call(handler, {
729
+ method: "verify",
730
+ namespace: "llm-pi-ai",
731
+ provider: "agnes",
732
+ modelId: "agnes-3.0-flash"
733
+ }));
734
+ assertDeepEqual(reply.body.verdict, "network", "verdict");
735
+ assert(reply.body.message.includes("ECONNREFUSED"), "the cause is surfaced");
736
+ delete process.env.TEST_KEY;
737
+ });
738
+
739
+ await test("verify: a missing credential is reported before any request", async () => {
740
+ const { settings, llm } = standard(piAiFixture());
741
+ const handler = mount(settings, llm);
742
+ let called = false;
743
+ const original = globalThis.fetch;
744
+ globalThis.fetch = async () => {
745
+ called = true;
746
+ throw new Error("must not be called");
747
+ };
748
+ try {
749
+ delete process.env.AGNES_API_KEY;
750
+ const reply = await call(handler, { method: "verify", namespace: "llm-pi-ai", provider: "agnes", modelId: "agnes-3.0-flash" });
751
+ assertDeepEqual(reply.body.code, "no-credential", "code");
752
+ assert(called === false, "no request may be attempted without a credential");
753
+ } finally {
754
+ globalThis.fetch = original;
755
+ }
756
+ });
757
+
758
+ // ── summary ────────────────────────────────────────────────────────────────
759
+
760
+ process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
761
+ process.exit(failed === 0 ? 0 : 1);