mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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,或留空。",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user