fix(provider): preserve model ID case and allow unknown vision overrides

Problem: Manual model setup rejected case-distinct IDs and disabled images
for newly available DeepSeek models.

Root cause: The dialog folded case during duplicate validation, while the
frontend and shared provider guard treated unknown DeepSeek IDs as text-only.

Fix: Reuse exact-ID validation with specific localized errors. Restrict the
hard image block to known text-only models and honor declared capability
for unknown models in configuration and provider requests.

Verification: Root and Desktop Go suites, three frontend regressions,
typecheck, full frontend build with bundle budgets, and browser interaction.
This commit is contained in:
SivanCola committed 2026-09-08 15:51:38 +08:00
1 parent 2180337194
commit e762260079
16 files changed
+96 -32

No files matched your search

@@ -58,8 +58,11 @@ const add=document.querySelector('.provider-model-toolbar .provider-model-draft_
await act(async()=>add.click());
await change(dialog().querySelector('.provider-model-dialog__id input')!,'model-a');await submit();
assert.ok(dialog().querySelector('[role="alert"]'),'duplicate IDs rejected');
await change(dialog().querySelector('.provider-model-dialog__id input')!,'model-b');await submit();
assert.equal(dialog().querySelector('[role="alert"]')!.textContent,'This model ID is already added.');
await change(dialog().querySelector('.provider-model-dialog__id input')!,'Model-A');await submit();
assert.equal(document.querySelectorAll('.provider-model-draft__option').length,2);
assert.equal(document.querySelectorAll('.provider-model-draft__option input:checked').length,2,'added model enabled in draft');
await act(async()=>save.click());
assert.deepEqual(saved.models,['model-a','Model-A'],'save preserves exact model IDs');
await act(async()=>root.unmount());
console.log('PASS: unified add/edit, isolated cancel, duplicate validation, complete model overrides and failed/successful save');
@@ -80,7 +80,7 @@ assert(!modeInput("off").disabled);
assert.equal(imageInputState("auto", { ...unknown, automaticState: undefined, source: "override", state: "supported" }), "unknown", "old backend must not fabricate automatic support");
assert.equal(imageInputState("on", { ...unknown, imageInputEnableAllowed: false }), "unsupported");
assert(imageInputHardBlocked("https://api.deepseek.com", "deepseek-v4-flash"));
assert(imageInputHardBlocked("https://eu.deepseek.com/anthropic", "future-vision"));
assert(!imageInputHardBlocked("https://eu.deepseek.com/anthropic", "future-vision"));
assert(!imageInputHardBlocked("https://api.deepseek.com.relay.test", "deepseek-v4-flash"));
assert(!imageInputHardBlocked("https://api.deepseek.com", "deepseek-v4-flash-vision-exp"));
assert.equal(mergeImageInputModes(initial.modelOverrides, initial.models, { "RELAY-MODEL": "off" })[0].vision, false);
@@ -20,6 +20,8 @@ assert.equal(result.contextWindow, 0);
assert.equal(result.maxOutputTokens, -1);
assert.equal(result.vision, null);
assert.equal(modelDraftError({ ...auto, model: "existing" }, ["existing"]), "duplicate");
assert.equal(modelDraftError({ ...auto, model: "Existing" }, ["existing"]), null);
assert.equal(modelDraftError({ ...auto, model: "bad model" }, []), "syntax");
assert.equal(modelDraftError({ ...auto, context: "1.5" }, []), "context");
assert.equal(modelDraftError({ ...auto, output: "0" }, []), "output");
@@ -1,10 +1,11 @@
import { useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { LockKeyhole, RotateCcw } from "lucide-react";
import { useT } from "../lib/i18n";
import { useProviderT as useT } from "../lib/providerSettingsLocale";
import { imageInputHardBlocked, imageInputState } from "../lib/providerImageInput";
import { ModalCloseButton } from "./ModalCloseButton";
import type { ProviderModelCapabilityView } from "../lib/types";
import { modelDraftError } from "../lib/providerModelDraft";
export interface ModelDetailsDraft { model: string; contextWindow: string; maxOutputTokens: number; vision: boolean | null; }
export default function ProviderModelDialog({ initial, candidates, contextDefault, capability, baseURL, busy, onClose, onApply }: {
@@ -24,13 +25,12 @@ export default function ProviderModelDialog({ initial, candidates, contextDefaul
dialog.current?.querySelector<HTMLInputElement>("input:not(:disabled)")?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, []);
const validNumber = (value: string, omit = false) => !value.trim() || (Number.isSafeInteger(Number(value)) && (Number(value) > 0 || (omit && Number(value) === -1)));
const valid = Boolean(model.trim()) && !/[\s,,]/.test(model.trim()) && (Boolean(initial) || !candidates.some(id => id.toLowerCase() === model.trim().toLowerCase())) && validNumber(context) && validNumber(output, true);
const validation = modelDraftError({model, context, output, vision: "auto"}, candidates, initial?.model);
const imageBlocked = imageInputHardBlocked(baseURL, model, capability);
const imageState = imageBlocked ? "unsupported" : imageInputState(vision === "auto" ? "auto" : vision === "true" ? "on" : "off", capability);
return createPortal(<dialog ref={dialog} className="provider-model-dialog" aria-labelledby={titleId}
onCancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
<form onSubmit={event => { event.preventDefault(); if (!valid) { setError(true); return; } onApply({ model:model.trim(), contextWindow:context.trim(), maxOutputTokens:Number(output) || 0, vision:vision === "auto" ? null : !imageBlocked && vision === "true" }); }}>
<form onSubmit={event => { event.preventDefault(); if (validation) { setError(true); return; } onApply({ model:model.trim(), contextWindow:context.trim(), maxOutputTokens:Number(output) || 0, vision:vision === "auto" ? null : !imageBlocked && vision === "true" }); }}>
<header><h2 id={titleId}>{t(initial ? "settings.modelDialog.edit" : "settings.models.add")}</h2><ModalCloseButton label={t("common.close")} disabled={busy} onClick={onClose}/></header>
<label className="provider-model-dialog__id">{t("settings.modelDialog.id")}
{initial ? <span><LockKeyhole size={16}/>{model}</span> : <input autoFocus className="mem-input" value={model} disabled={busy} onChange={e=>setModel(e.target.value)} placeholder="deepseek-v4-flash"/>}
@@ -62,7 +62,7 @@ export default function ProviderModelDialog({ initial, candidates, contextDefaul
<p>{t("settings.modelDialog.overrideHint")}</p>
</aside>
</div>
{error && <p role="alert">{t("settings.modelDialog.invalid")}</p>}
{error && validation && <p role="alert">{t(`providerUI.validation.${validation}`)}</p>}
<footer><small>{t("settings.modelDialog.draftHint")}</small><button type="button" className="btn" disabled={busy} onClick={onClose}>{t("common.cancel")}</button><button className="btn btn--primary" disabled={busy}>{t(initial ? "settings.modelDialog.apply" : "settings.modelDialog.add")}</button></footer>
</form>
</dialog>, document.body);
@@ -3,13 +3,13 @@ import type { ProviderModelCapabilityView, ProviderModelOverrideView } from "./t
export type ImageInputMode = "auto" | "on" | "off";
// A negative-only guard for manually typed models before a backend preview
// exists. Mirrors openai.IsDeepSeek / IsOfficialDeepSeekVisionModel; it never
// exists. Mirrors openai.IsDeepSeek / IsOfficialDeepSeekTextModel; it never
// infers positive capabilities from a model name.
export function imageInputHardBlocked(baseURL: string | undefined, model: string, capability?: ProviderModelCapabilityView): boolean {
if (capability?.imageInputEnableAllowed !== undefined) return !capability.imageInputEnableAllowed;
try {
return new URL(baseURL ?? "").hostname.toLowerCase().endsWith(".deepseek.com")
&& model.trim().toLowerCase() !== "deepseek-v4-flash-vision-exp";
&& ["deepseek-v4-flash", "deepseek-v4-pro"].includes(model.trim().toLowerCase());
} catch { return false; }
}
@@ -4,9 +4,10 @@ import type { ProviderModelOverrideView } from "./types";
export type ModelDraft = { model: string; context: string; output: string; vision: "auto" | "yes" | "no" };
/** Validate explicit overrides without turning an inherited value into a guessed limit. */
export function modelDraftError(draft: ModelDraft, existing: string[], original?: string): "required" | "duplicate" | "context" | "output" | null {
export function modelDraftError(draft: ModelDraft, existing: string[], original?: string): "required" | "duplicate" | "syntax" | "context" | "output" | null {
const model = draft.model.trim();
if (!model) return "required";
if (/[\s,,]/.test(model)) return "syntax";
if (existing.some((name) => name !== original && name === model)) return "duplicate";
const valid = (value: string, allowOmit = false) => !value.trim() || (Number.isSafeInteger(Number(value)) && (Number(value) > 0 || (allowOmit && Number(value) === -1)));
if (!valid(draft.context)) return "context";
@@ -31,6 +31,7 @@ const en = {
"providerUI.selected": "{n} new models selected",
"providerUI.addSelected": "Add selected models",
"providerUI.validation.required": "Model ID is required.",
"providerUI.validation.syntax": "Model ID cannot contain spaces or commas.",
"providerUI.validation.duplicate": "This model ID is already added.",
"providerUI.validation.context": "Context must be a positive integer or empty.",
"providerUI.validation.output": "Output limit must be a positive integer, -1, or empty.",
@@ -74,6 +75,7 @@ const zh: Record<keyof typeof en, string> = {
"providerUI.selected": "已选择 {n} 个新模型",
"providerUI.addSelected": "添加所选模型",
"providerUI.validation.required": "模型 ID 不能为空。",
"providerUI.validation.syntax": "模型 ID 不能包含空格或逗号。",
"providerUI.validation.duplicate": "此模型 ID 已存在。",
"providerUI.validation.context": "上下文窗口必须为正整数,或留空使用默认值。",
"providerUI.validation.output": "输出限制必须为正整数、-1,或留空。",
@@ -117,6 +119,7 @@ const zhTW: Record<keyof typeof en, string> = {
"providerUI.selected": "已選取 {n} 個新模型",
"providerUI.addSelected": "新增所選模型",
"providerUI.validation.required": "模型 ID 不可為空。",
"providerUI.validation.syntax": "模型 ID 不可包含空格或逗號。",
"providerUI.validation.duplicate": "此模型 ID 已存在。",
"providerUI.validation.context": "上下文視窗必須為正整數,或留空使用預設值。",
"providerUI.validation.output": "輸出限制必須為正整數、-1,或留空。",
+5 -5
View File
@@ -1679,7 +1679,7 @@ func TestNewProviderBuildsDeepSeekAnthropicPreset(t *testing.T) {
}
}
func TestNewProviderRejectsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
func TestNewProviderAllowsExplicitUnknownDeepSeekVisionModel(t *testing.T) {
var gotReq map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
@@ -1724,15 +1724,15 @@ func TestNewProviderRejectsExplicitOfficialDeepSeekVisionModel(t *testing.T) {
if !ok {
t.Fatalf("message = %#v, want object", messages[0])
}
if got, ok := message["content"].(string); !ok || got != "describe" {
t.Fatalf("content = %#v, want plain text despite stale explicit vision metadata", message["content"])
if got, ok := message["content"].([]any); !ok || len(got) != 2 {
t.Fatalf("content = %#v, want text and explicitly enabled image", message["content"])
}
encoded, err := json.Marshal(gotReq)
if err != nil {
t.Fatalf("marshal captured request: %v", err)
}
if bytes.Contains(encoded, []byte("image_url")) || bytes.Contains(encoded, []byte("base64,AAAA")) {
t.Fatalf("official DeepSeek request leaked image payload: %s", encoded)
if !bytes.Contains(encoded, []byte("image_url")) || !bytes.Contains(encoded, []byte("base64,AAAA")) {
t.Fatalf("explicitly enabled image missing: %s", encoded)
}
}
+4 -4
View File
@@ -788,8 +788,8 @@ func TestEffectiveVisionRejectsOfficialDeepSeekOverridesButPreservesCustomGatewa
Model: "deepseek-v5-vision",
VisionModels: []string{"deepseek-v5-vision"},
}
if EffectiveVision(future) {
t.Fatal("a future model name must not bypass the official DeepSeek wire constraint")
if !EffectiveVision(future) {
t.Fatal("explicit vision model list must support unknown DeepSeek models")
}
visionOn := true
@@ -806,8 +806,8 @@ func TestEffectiveVisionRejectsOfficialDeepSeekOverridesButPreservesCustomGatewa
if !ok {
t.Fatal("ResolveModel did not find explicit future DeepSeek model")
}
if EffectiveVision(overridden) {
t.Fatal("model_overrides vision=true must not bypass the official DeepSeek wire constraint")
if !EffectiveVision(overridden) {
t.Fatal("model_overrides vision=true must enable unknown DeepSeek models")
}
custom := &ProviderEntry{
+1 -1
View File
@@ -139,7 +139,7 @@ func (r *ModelCapabilityResolver) resolveWithCredentialRevision(entry *ProviderE
if requestURL == "" && entry.Kind == "openai" {
requestURL = entry.ChatURL
}
if (openai.IsDeepSeek(entry.BaseURL) || openai.IsDeepSeek(requestURL)) && !openai.IsOfficialDeepSeekVisionModel(entry.Model) {
if (openai.IsDeepSeek(entry.BaseURL) || openai.IsDeepSeek(requestURL)) && openai.IsOfficialDeepSeekTextModel(entry.Model) {
resolved.State, resolved.Source = CapabilityUnsupported, CapabilitySourceProtocol
resolved.InputModalities = []provider.ModelModality{provider.ModalityText}
resolved.AutomaticState, resolved.AutomaticSource = CapabilityUnsupported, CapabilitySourceProtocol
+25
View File
@@ -0,0 +1,25 @@
package config
import "testing"
func TestUnknownDeepSeekVisionOverride(t *testing.T) {
model := "deepseek-v4.1-flash-expires-on-0910"
enabled := true
e := ProviderEntry{Kind: "openai", BaseURL: "https://api.deepseek.com", Model: model}
r := NewModelCapabilityResolver()
if got := r.Resolve(&e); !got.ImageInputEnableAllowed || got.State != CapabilityUnknown {
t.Fatalf("unknown model should permit manual declaration: %+v", got)
}
e.ModelOverrides = map[string]ProviderModelOverride{model: {Vision: &enabled}}
if got := r.Resolve(&e); !got.ImageInputEnableAllowed || got.State != CapabilitySupported {
t.Fatalf("override rejected: %+v", got)
}
e.Name = "deepseek-test"
e.Model = ""
e.Models = []string{"DeepSeek-V4.1-Flash-Expires-On-0910", model}
cfg := Config{Providers: []ProviderEntry{e}}
resolved, ok := cfg.ResolveModel(e.Name + "/" + model)
if !ok || resolved.Model != model || !EffectiveVision(resolved) {
t.Fatalf("lowercase model and its vision override did not resolve: %+v", resolved)
}
}
+4 -1
View File
@@ -141,12 +141,15 @@ func ExplicitModelVision(e *ProviderEntry) bool {
}
func officialDeepSeekEffectiveVision(e *ProviderEntry) bool {
if e == nil || !openai.IsOfficialDeepSeekVisionModel(e.Model) {
if e == nil || openai.IsOfficialDeepSeekTextModel(e.Model) {
return false
}
if enabled, explicit := explicitModelVision(e); explicit {
return enabled
}
if !openai.IsOfficialDeepSeekVisionModel(e.Model) {
return false
}
if e.Vision {
return true
}
+15 -4
View File
@@ -37,9 +37,8 @@ func IsDeepSeek(baseURL string) bool {
return matchesVendorHost(baseURL, "deepseek.com", "api.deepseek.com")
}
// OfficialDeepSeekVisionModel is the only official DeepSeek chat SKU that
// accepts image input. Flash and Pro remain text-only; a future name that
// merely contains "vision" must not inherit this contract.
// OfficialDeepSeekVisionModel has built-in image support. Unknown models need
// capability metadata or an explicit declaration, not a name-based guess.
const OfficialDeepSeekVisionModel = "deepseek-v4-flash-vision-exp"
// IsOfficialDeepSeekVisionModel reports whether model is the pinned official
@@ -48,13 +47,25 @@ func IsOfficialDeepSeekVisionModel(model string) bool {
return strings.EqualFold(strings.TrimSpace(model), OfficialDeepSeekVisionModel)
}
// IsOfficialDeepSeekTextModel identifies known text-only models, not future SKUs.
func IsOfficialDeepSeekTextModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case "deepseek-v4-flash", "deepseek-v4-pro":
return true
}
return false
}
// DeepSeekImageInputAllowed applies the official endpoint hard limit after a
// provider has resolved its configured or catalog-derived image capability.
func DeepSeekImageInputAllowed(officialBase bool, requestURL, model string, metadataProvided, enabled bool) bool {
if !officialBase && !IsDeepSeek(requestURL) {
return enabled
}
return (!metadataProvided || enabled) && IsOfficialDeepSeekVisionModel(model)
if IsOfficialDeepSeekTextModel(model) {
return false
}
return enabled || (!metadataProvided && IsOfficialDeepSeekVisionModel(model))
}
// OfficialDeepSeekAllowsVision reports whether this official DeepSeek endpoint
+5 -5
View File
@@ -121,7 +121,7 @@ func TestOfficialDeepSeekProviderWideVisionInputMatchesTextOnlyRequest(t *testin
}
}
func TestOfficialDeepSeekExplicitModelVisionInputMatchesTextOnlyRequest(t *testing.T) {
func TestOfficialDeepSeekUnknownModelExplicitVisionSerializesImages(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
@@ -135,8 +135,8 @@ func TestOfficialDeepSeekExplicitModelVisionInputMatchesTextOnlyRequest(t *testi
t.Fatalf("New: %v", err)
}
c := p.(*client)
if c.vision {
t.Fatal("explicit model-scoped vision must not bypass the official DeepSeek endpoint guard")
if !c.vision {
t.Fatal("explicit vision must enable unknown DeepSeek models")
}
textOnly := provider.Request{Messages: []provider.Message{{
@@ -154,8 +154,8 @@ func TestOfficialDeepSeekExplicitModelVisionInputMatchesTextOnlyRequest(t *testi
if err != nil {
t.Fatalf("marshal image request: %v", err)
}
if !bytes.Equal(imageBody, textBody) {
t.Fatalf("explicit official DeepSeek image request changed provider-visible bytes:\ntext: %s\nimage: %s", textBody, imageBody)
if bytes.Equal(imageBody, textBody) || !bytes.Contains(imageBody, []byte("data:image/png;base64,AAAA")) {
t.Fatalf("explicit unknown DeepSeek image missing from request: %s", imageBody)
}
}
+1 -2
View File
@@ -100,8 +100,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
if cfg.ModelInfo != nil {
vision = modelInfo.SupportsInput(provider.ModalityImage)
}
// Official DeepSeek image input is pinned to one SKU even when a catalog
// or gateway metadata entry claims otherwise.
// Keep known text-only models blocked; unknown models use declared capability.
vision = DeepSeekImageInputAllowed(officialDeepSeek, chatURL, cfg.Model, cfg.ModelInfo != nil, vision)
if vision {
modelInfo.InputModalities = []provider.ModelModality{provider.ModalityText, provider.ModalityImage}
@@ -0,0 +1,17 @@
package openai
import "testing"
func TestUnknownDeepSeekVisionRequiresDeclaration(t *testing.T) {
for _, model := range []string{"deepseek-v4.1-flash-expires-on-0910", "future-vision"} {
if DeepSeekImageInputAllowed(true, "", model, false, false) {
t.Fatalf("%s inferred image support without declaration", model)
}
if !DeepSeekImageInputAllowed(true, "", model, true, true) {
t.Fatalf("%s ignored explicit image declaration", model)
}
}
if DeepSeekImageInputAllowed(true, "", "deepseek-v4-flash", true, true) {
t.Fatal("known text-only model accepted images")
}
}