返回 DeepSeek-Reasonix
provider-editor-model-picker.test.tsx
根目录 / desktop / frontend / src / __tests__ / provider-editor-model-picker.test.tsx
1 import { settingsOptionValues } from "./settingsSelectTestUtils";
2 // Run: tsx src/__tests__/provider-editor-model-picker.test.tsx
3
4 import { JSDOM } from "jsdom";
5 import React from "react";
6 import { act } from "react";
7 import type { ProviderView } from "../lib/types";
8
9 let passed = 0;
10 let failed = 0;
11
12 function ok(value: boolean, label: string) {
13 if (value) {
14 process.stdout.write(` PASS ${label}\n`);
15 passed += 1;
16 } else {
17 process.stdout.write(` FAIL ${label}\n`);
18 failed += 1;
19 }
20 }
21
22 function flushPromises(): Promise<void> {
23 return new Promise((resolve) => setTimeout(resolve, 0));
24 }
25
26 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
27 pretendToBeVisual: true,
28 url: "http://localhost/",
29 });
30 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
31 globalThis.window = dom.window as unknown as Window & typeof globalThis;
32 globalThis.document = dom.window.document;
33 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
34 globalThis.Node = dom.window.Node;
35 globalThis.HTMLElement = dom.window.HTMLElement;
36 globalThis.Event = dom.window.Event;
37 globalThis.CustomEvent = dom.window.CustomEvent;
38 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
39 globalThis.MouseEvent = dom.window.MouseEvent;
40 globalThis.localStorage = dom.window.localStorage;
41 globalThis.sessionStorage = dom.window.sessionStorage;
42 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
43 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
44 window.scrollTo = () => {};
45 window.matchMedia = (() => ({matches: true, addEventListener(){}, removeEventListener(){}})) as any;
46 const { createRoot } = await import("react-dom/client");
47 const { LocaleProvider } = await import("../lib/i18n");
48 const { ProviderEditor, ProviderEditorModelPicker, providerSupportsServerWebSearch, providerSupportsServerWebSearchForView, providerVisionCapabilityForView } = await import("../components/SettingsPanel");
49 function editDisplayName() {
50 const input = rootEl!.querySelector<HTMLInputElement>(".provider-name-input")!;
51 Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(input, "Display label");
52 input.dispatchEvent(new window.Event("input", { bubbles: true }));
53 }
54
55 function renderPicker(
56 candidates: string[],
57 options: {
58 visionModels?: string[];
59 visionModelsConfigured?: boolean;
60 visionCapability?: "configurable" | "unsupported";
61 modelCapabilities?: ProviderModelCapabilityView[];
62 } = {},
63 ) {
64 const {
65 visionModels = [],
66 visionModelsConfigured = false,
67 visionCapability = "configurable",
68 modelCapabilities = [],
69 } = options;
70 return (
71 <LocaleProvider>
72 <ProviderEditorModelPicker
73 candidates={candidates}
74 selectedModels={[]}
75 visionModels={visionModels}
76 visionModelsConfigured={visionCapability === "unsupported"}
77 modelCapabilities={candidates.map(model => ({ model, state: visionModels.includes(model) ? "supported" : "unsupported" }))}
78 contextWindows={{}}
79 disabled={false}
80 onToggleModel={() => undefined}
81 onContextWindowChange={() => undefined}
82 onSelectAll={() => undefined}
83 onClear={() => undefined}
84 />
85 </LocaleProvider>
86 );
87 }
88
89 console.log("\nprovider editor model picker");
90
91 const rootEl = document.getElementById("root");
92 if (!rootEl) throw new Error("missing root");
93 const root = createRoot(rootEl);
94
95 let threw = false;
96 try {
97 await act(async () => {
98 root.render(renderPicker([]));
99 await flushPromises();
100 });
101 await act(async () => {
102 root.render(renderPicker(["zen-v1", "zen-v1-pro"]));
103 await flushPromises();
104 });
105 } catch (error) {
106 threw = true;
107 process.stdout.write(` ERROR ${String(error)}\n`);
108 }
109
110 ok(!threw, "model picker can render after async model fetch returns candidates");
111 ok(rootEl.textContent?.includes("zen-v1") === true, "model picker shows fetched custom provider models");
112
113 await act(async () => {
114 root.render(renderPicker(["deepseek-v4-flash"], {
115 modelCapabilities: [{
116 model: "deepseek-v4-flash",
117 inputModalities: ["text"],
118 state: "unsupported",
119 source: "adapter",
120 }],
121 }));
122 await flushPromises();
123 });
124 ok(rootEl.querySelectorAll(".provider-model-draft__capabilities span").length === 0, "known text-only models omit redundant capability badges");
125 ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "text-only model card does not render a second image checkbox");
126 await act(async () => {
127 root.render(renderPicker(["deepseek-v4-flash-vision-exp"], {
128 visionModels: ["deepseek-v4-flash-vision-exp"],
129 visionModelsConfigured: true,
130 }));
131 await flushPromises();
132 });
133 ok(rootEl.textContent?.includes("No image input") !== true, "pinned DeepSeek vision SKU does not show the text-only image label");
134 ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "pinned DeepSeek vision SKU does not render a configurable image checkbox");
135 await act(async () => {
136 root.render(renderPicker(
137 ["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"],
138 {
139 modelCapabilities: [
140 {
141 model: "deepseek-v4-flash",
142 inputModalities: ["text"],
143 state: "unsupported",
144 source: "adapter",
145 },
146 {
147 model: "deepseek-v4-flash-vision-exp",
148 inputModalities: ["text", "image"],
149 state: "supported",
150 source: "adapter",
151 },
152 ],
153 },
154 ));
155 await flushPromises();
156 });
157 ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 2, "model capabilities remain read-only rather than introducing image-input checkboxes");
158 ok(rootEl.textContent?.includes("No image input") !== true, "configurable DeepSeek models do not use the read-only image-unsupported label");
159 ok(providerSupportsServerWebSearch("responses", "https://api.deepseek.com"), "DeepSeek Responses exposes server-side web search");
160 ok(providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com/anthropic"), "DeepSeek Anthropic exposes server-side web search");
161 ok(providerSupportsServerWebSearch("openai", "https://api.deepseek.com"), "DeepSeek Chat Completions exposes independent web search");
162 ok(providerSupportsServerWebSearch("openai", "https://api.deepseek.com/v1"), "versioned official Chat Completions supports independent search");
163 ok(!providerSupportsServerWebSearch("openai", "https://gateway.example/v1"), "custom Chat Completions is not assumed to support search");
164 ok(!providerSupportsServerWebSearch("responses", "https://relay.deepseek.com"), "DeepSeek-like subdomains do not inherit official defaults");
165 ok(!providerSupportsServerWebSearch("responses", "https://api.deepseek.com/anthropic"), "Responses rejects the Anthropic base path");
166 ok(!providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com"), "Anthropic requires its documented base path");
167 ok(!providerSupportsServerWebSearch("responses", "http://api.deepseek.com"), "insecure DeepSeek URLs do not inherit official defaults");
168 ok(
169 providerSupportsServerWebSearchForView({
170 kind: "anthropic",
171 baseUrl: "https://gateway.example/anthropic",
172 serverWebSearchCapability: true,
173 }),
174 "backend verification can enable server-side web search for future curated endpoints",
175 );
176 ok(
177 !providerSupportsServerWebSearchForView({
178 kind: "anthropic",
179 baseUrl: "https://api.deepseek.com/anthropic",
180 serverWebSearchCapability: false,
181 }),
182 "backend capability overrides endpoint inference when explicitly unsupported",
183 );
184 ok(
185 providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic" }),
186 "older backend payloads retain the exact DeepSeek endpoint fallback",
187 );
188 ok(
189 !providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://gateway.example/anthropic" }),
190 "older backend payloads do not infer support for arbitrary compatible endpoints",
191 );
192 ok(
193 providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://custom.example/v1", visionCapability: "unsupported" }) === "unsupported",
194 "backend vision capability overrides frontend endpoint inference",
195 );
196 ok(
197 providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "configurable",
198 "official DeepSeek Settings uses the same image-input checkboxes as other providers",
199 );
200 ok(
201 providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "configurable",
202 "regional DeepSeek Settings also expose per-model image-input checkboxes",
203 );
204 ok(
205 providerVisionCapabilityForView({ kind: "responses", baseUrl: "https://deepseek.com" }) === "configurable",
206 "the DeepSeek apex does not inherit official vision restrictions",
207 );
208 ok(
209 providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://deepseek.com.example/v1" }) === "configurable",
210 "lookalike domains with a DeepSeek prefix do not inherit official vision restrictions",
211 );
212 ok(
213 providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com.example/v1" }) === "configurable",
214 "lookalike domains with an official-host prefix remain configurable",
215 );
216
217 const builtInProvider: ProviderView = {
218 name: "deepseek",
219 builtIn: true,
220 added: true,
221 kind: "openai",
222 baseUrl: "https://api.deepseek.com",
223 models: ["deepseek-chat"],
224 visionModels: [],
225 visionModelsConfigured: false,
226 modelsUrl: "",
227 default: "deepseek-chat",
228 apiKeyEnv: "",
229 keySet: false,
230 balanceUrl: "",
231 contextWindow: 128_000,
232 reasoningProtocol: "deepseek",
233 thinking: "",
234 supportedEfforts: [],
235 defaultEffort: "",
236 };
237
238 const deepSeekResponsesProvider: ProviderView = {
239 ...builtInProvider,
240 name: "deepseek-responses",
241 builtIn: false,
242 kind: "responses",
243 models: ["deepseek-v4-flash"],
244 default: "deepseek-v4-flash",
245 webSearch: true,
246 serverWebSearchCapability: true,
247 modelCapabilities: [{
248 model: "deepseek-v4-flash",
249 inputModalities: ["text"],
250 state: "unsupported",
251 source: "adapter",
252 }],
253 };
254
255 const longCatAnthropicProvider: ProviderView = {
256 ...builtInProvider,
257 name: "longcat-anthropic",
258 builtIn: false,
259 kind: "anthropic",
260 baseUrl: "https://api.longcat.chat/anthropic",
261 models: ["LongCat-2.0"],
262 default: "LongCat-2.0",
263 serverWebSearchCapability: false,
264 };
265
266 const backendUnsupportedCustomProvider: ProviderView = {
267 ...builtInProvider,
268 name: "deepseek-regional",
269 builtIn: false,
270 baseUrl: "https://eu.deepseek.com/v1",
271 models: ["deepseek-v4-pro"],
272 default: "deepseek-v4-pro",
273 modelCapabilities: [
274 { model: "deepseek-v4-pro", inputModalities: ["text"], state: "unsupported", source: "adapter" },
275 ],
276 };
277
278 const legacyChatURLProvider: ProviderView = {
279 ...backendUnsupportedCustomProvider,
280 name: "legacy-chat-url",
281 chatUrl: "https://legacy.example.com/chat/completions/",
282 };
283
284 const mismatchedDeepSeekProvider: ProviderView = {
285 ...builtInProvider,
286 name: "deepseek-anthropic",
287 displayName: "Deepseek2",
288 builtIn: false,
289 presetId: "deepseek-anthropic",
290 kind: "openai",
291 baseUrl: "https://api.deepseek.com/anthropic/v1",
292 requestUrl: "https://api.deepseek.com/anthropic/v1/chat/completions",
293 catalog: {
294 brandId: "deepseek", brandLabel: "DeepSeek", region: "global", product: "api", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic",
295 protocols: {
296 openai: { baseUrl: "https://api.deepseek.com/v1", source: "fixture", checkedOn: "2026-09-08" },
297 responses: { baseUrl: "https://api.deepseek.com", source: "fixture", checkedOn: "2026-09-08" },
298 anthropic: { baseUrl: "https://api.deepseek.com/anthropic", source: "fixture", checkedOn: "2026-09-08" },
299 },
300 },
301 };
302
303 function renderProviderEditor(initial?: ProviderView, onSave: (provider: ProviderView) => void | Promise<void> = () => undefined) {
304 return (
305 <LocaleProvider>
306 <ProviderEditor
307 key={initial?.name ?? "new-provider"}
308 initial={initial}
309 kinds={["openai"]}
310 busy={false}
311 onCancel={() => undefined}
312 onSave={onSave}
313 />
314 </LocaleProvider>
315 );
316 }
317
318 let editorThrew = false;
319 try {
320 await act(async () => {
321 root.render(renderProviderEditor(builtInProvider));
322 await flushPromises();
323 });
324 await act(async () => {
325 root.render(renderProviderEditor());
326 await flushPromises();
327 });
328 } catch (error) {
329 editorThrew = true;
330 process.stdout.write(` ERROR ${String(error)}\n`);
331 }
332
333 ok(!editorThrew, "provider editor can switch from built-in to custom without changing hook order");
334 ok(rootEl.textContent?.includes("Chat Completions (/chat/completions)") === true, "provider editor renders the custom provider fields after the switch");
335 ok((await settingsOptionValues(rootEl.querySelector<HTMLButtonElement>('button[aria-label="Model capability mode"]')!)).includes("kimi-k3"), "custom provider editor exposes the explicit Kimi K3 reasoning protocol");
336 const providerUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input");
337 ok(rootEl.querySelectorAll('input[type="radio"]:not(.sr-only)').length === 0, "custom provider editor exposes only one API address input");
338 ok(providerUrlInput?.value === "", "new custom providers start with an empty exact request address");
339 const providerUrlLabel = Array.from(rootEl.querySelectorAll<HTMLLabelElement>("label")).find(
340 (label) => label.htmlFor === providerUrlInput?.id,
341 );
342 ok(Boolean(providerUrlLabel) && providerUrlInput?.getAttribute("aria-describedby") !== null, "provider URL input has a programmatic label and description");
343 ok(rootEl.textContent?.includes("uses it unchanged") === true, "provider URL helper explains exact request behavior");
344 ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled !== true, "new custom provider name stays editable");
345 ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.classList.contains("mem-hint") !== true, "new custom provider editor omits the rename hint");
346
347 await act(async () => {
348 root.render(<div />);
349 await flushPromises();
350 });
351 await act(async () => {
352 root.render(renderProviderEditor(deepSeekResponsesProvider));
353 await flushPromises();
354 });
355 const webSearchSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
356 ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Responses editor separates service capabilities from model selection");
357 ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor");
358 ok(rootEl.querySelectorAll('.provider-model-draft__option input[type="checkbox"]').length === 1, "DeepSeek Responses exposes one model-selection checkbox");
359 ok(rootEl.textContent?.includes("No image input") !== true, "DeepSeek Responses editor does not use the read-only image-unsupported label");
360
361 await act(async () => {
362 root.render(renderProviderEditor(longCatAnthropicProvider));
363 await flushPromises();
364 });
365 ok(rootEl.textContent?.includes("Server-side web search") === false, "unverified LongCat Anthropic endpoint hides server-side web search");
366 ok(rootEl.querySelector<HTMLInputElement>('input[role="switch"]') === null, "unverified LongCat endpoint exposes no recommended web-search switch");
367
368 await act(async () => {
369 root.render(renderProviderEditor(backendUnsupportedCustomProvider));
370 await flushPromises();
371 });
372 ok(
373 rootEl.querySelectorAll(".provider-model-draft__capabilities span").length === 0,
374 "backend text-only metadata omits image badges outside the legacy endpoint heuristic",
375 );
376 const displayedRequestURL = (input: HTMLInputElement | null) =>
377 input?.value;
378 const customProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input");
379 ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "existing custom providers no longer expose an address mode selector");
380 ok(customProviderUrlInput?.value === "https://eu.deepseek.com/v1/chat/completions", "legacy base-only providers display their previously effective request URL");
381 ok(rootEl.querySelector<HTMLInputElement>(".provider-name-input")?.disabled === false, "existing connection display name remains editable");
382 ok(rootEl.querySelector("code")?.textContent === backendUnsupportedCustomProvider.name, "stable provider identity remains separate from display name");
383
384 let migratedProvider: ProviderView | undefined;
385 await act(async () => {
386 root.render(renderProviderEditor(legacyChatURLProvider, (provider) => {
387 migratedProvider = provider;
388 }));
389 await flushPromises();
390 });
391 const legacyProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input");
392 ok( displayedRequestURL(legacyProviderUrlInput) === "https://legacy.example.com/chat/completions", "legacy OpenAI chat URLs display their historically normalized effective endpoint");
393 const saveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find(
394 (button) => button.textContent?.trim() === "Save changes",
395 );
396 await act(async () => editDisplayName());
397 await act(async () => {
398 saveButton?.click();
399 await flushPromises();
400 });
401 ok(migratedProvider?.requestUrl === "https://legacy.example.com/chat/completions" && migratedProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving migrates the legacy effective address without rewriting its baseUrl");
402 ok(migratedProvider?.chatUrl === migratedProvider?.requestUrl, "saving mirrors the normalized legacy OpenAI endpoint for previous releases");
403
404 let exactProvider: ProviderView | undefined;
405 await act(async () => {
406 root.render(renderProviderEditor({
407 ...legacyChatURLProvider,
408 name: "exact-request-url",
409 requestUrl: "https://exact.example.com/custom/?token=1",
410 }, (provider) => {
411 exactProvider = provider;
412 }));
413 await flushPromises();
414 });
415 const exactSaveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find(
416 (button) => button.textContent?.trim() === "Save changes",
417 );
418 await act(async () => editDisplayName());
419 await act(async () => {
420 exactSaveButton?.click();
421 await flushPromises();
422 });
423 ok(exactProvider?.requestUrl === "https://exact.example.com/custom/?token=1" && exactProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving preserves an explicit requestUrl and independent baseUrl exactly");
424 ok(exactProvider?.chatUrl === exactProvider?.requestUrl, "saving mirrors the exact OpenAI request URL for previous releases");
425
426 await act(async () => {
427 root.render(renderProviderEditor(mismatchedDeepSeekProvider));
428 await flushPromises();
429 });
430 const mismatchAlert = rootEl.querySelector<HTMLElement>('[role="alert"]');
431 const mismatchSave = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Save changes");
432 const useRecommended = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Apply" && button.title === "https://api.deepseek.com/v1/chat/completions");
433 ok(mismatchAlert?.textContent?.includes("does not match") === true && mismatchSave?.disabled === true, "protocol mismatch is a blocking editor error");
434 await act(async () => { useRecommended?.click(); await flushPromises(); });
435 ok(rootEl.querySelector<HTMLInputElement>(".provider-url-input")?.value === "https://api.deepseek.com/v1/chat/completions", "recommended action applies the catalog request URL");
436 ok(rootEl.querySelector<HTMLElement>('[role="alert"]') === null, "recommended route clears the mismatch gate");
437
438 await act(async () => {
439 root.render(renderProviderEditor({ ...legacyChatURLProvider, name: "save-failure" }, () => { throw new Error("storage unavailable"); }));
440 await flushPromises();
441 });
442 const failedSaveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Save changes");
443 await act(async () => editDisplayName());
444 await act(async () => { failedSaveButton?.click(); await flushPromises(); });
445 ok(rootEl.textContent?.includes("storage unavailable") === true, "save errors remain visible in the provider form");
446 ok(rootEl.querySelector<HTMLInputElement>(".provider-url-input")?.value === "https://legacy.example.com/chat/completions" && rootEl.querySelector(".provider-model-draft__option") !== null, "failed save retains connection and model drafts");
447
448 await act(async () => {
449 root.unmount();
450 });
451 dom.window.close();
452
453 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
454 if (failed > 0) process.exit(1);
455
455 lines Plain Text