Merge pull request #6908 from myipanta/fix/deepseek-image-400

fix(provider): gate current DeepSeek images with per-model opt-in / DeepSeek 当前模型默认禁图并支持按模型放行
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-31 00:45:33 +08:00
commit 0c0422c877
6 files changed
+299 -19

No files matched your search

+14 -13
View File
@@ -2213,19 +2213,20 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// provider-kind-specific knobs. EffectiveEffort applies a configured
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"chat_url": e.ChatURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": e.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"chat_url": e.ChatURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_model_explicit": config.ExplicitModelVision(e),
"vision_detail": e.VisionDetail,
},
})
}
+55
View File
@@ -1453,6 +1453,61 @@ func TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {
}
}
func TestNewProviderAllowsExplicitOfficialDeepSeekVisionModel(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 {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"))
}))
defer srv.Close()
p, err := NewProvider(&config.ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
ChatURL: srv.URL,
Model: "deepseek-v5-vision",
VisionModels: []string{"deepseek-v5-vision"},
})
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/png;base64,AAAA"},
}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
messages, ok := gotReq["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v, want one message", gotReq["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok {
t.Fatalf("message = %#v, want object", messages[0])
}
parts, ok := message["content"].([]any)
if !ok || len(parts) != 2 {
t.Fatalf("content = %#v, want [text, image_url]", message["content"])
}
imagePart, ok := parts[1].(map[string]any)
if !ok || imagePart["type"] != "image_url" {
t.Fatalf("image part = %#v, want image_url", parts[1])
}
}
func TestBuildHonorsSessionDirOverride(t *testing.T) {
dir := t.TempDir()
home := t.TempDir()
+64
View File
@@ -707,6 +707,70 @@ func TestEffectiveVisionDoesNotInferCustomMimoProxy(t *testing.T) {
}
}
func TestEffectiveVisionDefaultsOfficialDeepSeekToTextOnlyButAllowsExplicitModels(t *testing.T) {
for _, baseURL := range []string{
"https://api.deepseek.com",
"https://api.deepseek.com/v1",
"https://eu.deepseek.com/v1",
} {
official := &ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: baseURL,
Model: "deepseek-v4-pro",
Vision: true,
ReasoningProtocol: ReasoningProtocolDeepSeek,
}
if EffectiveVision(official) {
t.Fatalf("official DeepSeek endpoint %q must remain text-only", baseURL)
}
if ExplicitModelVision(official) {
t.Fatalf("provider-wide vision must not count as an explicit model capability for %q", baseURL)
}
}
future := &ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v5-vision",
VisionModels: []string{"deepseek-v5-vision"},
}
if !EffectiveVision(future) || !ExplicitModelVision(future) {
t.Fatal("model listed in vision_models must opt in on the official DeepSeek endpoint")
}
visionOn := true
cfg := &Config{Providers: []ProviderEntry{{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Models: []string{"deepseek-v5-override"},
ModelOverrides: map[string]ProviderModelOverride{
"deepseek-v5-override": {Vision: &visionOn},
},
}}}
overridden, ok := cfg.ResolveModel("deepseek/deepseek-v5-override")
if !ok {
t.Fatal("ResolveModel did not find explicit future DeepSeek model")
}
if !EffectiveVision(overridden) || !ExplicitModelVision(overridden) {
t.Fatal("model_overrides vision=true must opt in on the official DeepSeek endpoint")
}
custom := &ProviderEntry{
Name: "deepseek-gateway",
Kind: "openai",
BaseURL: "https://gateway.example/v1",
Model: "deepseek-v4-pro",
Vision: true,
ReasoningProtocol: ReasoningProtocolDeepSeek,
}
if !EffectiveVision(custom) {
t.Fatal("explicit vision=true must remain available for custom DeepSeek gateways")
}
}
func TestEffectiveVisionUsesPerModelVisionList(t *testing.T) {
c := &Config{Providers: []ProviderEntry{{
Name: "custom",
+34 -5
View File
@@ -3,6 +3,8 @@ package config
import (
"net/url"
"strings"
"reasonix/internal/provider/openai"
)
var mimoVisionModels = map[string]bool{
@@ -66,18 +68,45 @@ func EffectiveVision(e *ProviderEntry) bool {
if e == nil {
return false
}
if e.visionOverride != nil {
return *e.visionOverride
if enabled, explicit := explicitModelVision(e); explicit {
return enabled
}
// DeepSeek's official chat API accepts string message content only. Treat
// current official models as text-only when only the legacy provider-wide
// vision flag is set, so stale configs cannot make requests 400. A concrete
// model can still opt in through vision_models or a model override when
// DeepSeek documents a future multimodal request shape.
if isOpenAIProviderKind(e) && openai.IsDeepSeek(e.BaseURL) {
return false
}
if e.Vision {
return true
}
if e.HasVisionModel(e.Model) {
return true
}
return isOfficialMimoVisionEntry(e)
}
// ExplicitModelVision reports whether the selected model has a positive,
// model-scoped image capability declaration. Provider assembly uses this
// provenance to distinguish a deliberate future-model opt-in from a stale
// provider-wide vision=true setting.
func ExplicitModelVision(e *ProviderEntry) bool {
enabled, explicit := explicitModelVision(e)
return explicit && enabled
}
func explicitModelVision(e *ProviderEntry) (enabled, explicit bool) {
if e == nil {
return false, false
}
if e.visionOverride != nil {
return *e.visionOverride, true
}
if e.HasVisionModel(e.Model) {
return true, true
}
return false, false
}
func (e *ProviderEntry) HasVisionModel(model string) bool {
model = strings.TrimSpace(model)
if model == "" {
+123
View File
@@ -1,6 +1,7 @@
package openai
import (
"bytes"
"encoding/json"
"strings"
"testing"
@@ -43,6 +44,128 @@ func TestBuildRequestSkipsImagesWithoutVision(t *testing.T) {
}
}
func TestOfficialDeepSeekProviderWideVisionInputMatchesTextOnlyRequest(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro",
Extra: map[string]any{"vision": true},
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if c.vision {
t.Fatal("official DeepSeek endpoint must ignore stale vision=true config")
}
textOnly := provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe this image",
}}}
withImage := provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe this image",
Images: []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)},
}}}
textBody, err := json.Marshal(c.buildRequest(textOnly))
if err != nil {
t.Fatalf("marshal text request: %v", err)
}
imageBody, err := json.Marshal(c.buildRequest(withImage))
if err != nil {
t.Fatalf("marshal image request: %v", err)
}
if !bytes.Equal(imageBody, textBody) {
t.Fatalf("official DeepSeek image request changed provider-visible bytes:\ntext: %s\nimage: %s", textBody, imageBody)
}
}
func TestOfficialDeepSeekExplicitFutureVisionInputUsesImageParts(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v5-vision",
Extra: map[string]any{
"vision": true,
"vision_model_explicit": true,
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if !c.vision {
t.Fatal("explicit model-scoped vision must remain enabled on the official DeepSeek endpoint")
}
req := c.buildRequest(provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/png;base64,AAAA"},
}}})
parts, ok := req.Messages[0].Content.([]chatContentPart)
if !ok || len(parts) != 2 || parts[1].ImageURL == nil {
t.Fatalf("explicit future DeepSeek content = %#v, want [text, image_url]", req.Messages[0].Content)
}
}
func TestOfficialDeepSeekDoesNotInjectToolResultImages(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-pro",
Extra: map[string]any{"vision": true},
})
if err != nil {
t.Fatalf("New: %v", err)
}
req := p.(*client).buildRequest(provider.Request{Messages: []provider.Message{
{Role: provider.RoleUser, Content: "take a screenshot"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "c1", Name: "shot", Arguments: "{}",
}}},
{
Role: provider.RoleTool, ToolCallID: "c1", Name: "shot",
Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"},
},
}})
if len(req.Messages) != 3 {
t.Fatalf("messages = %d, want 3 without an injected image message", len(req.Messages))
}
body, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
if strings.Contains(string(body), "image_url") || strings.Contains(string(body), "base64,AAAA") {
t.Fatalf("official DeepSeek request leaked tool image payload: %s", body)
}
}
func TestCustomDeepSeekProtocolGatewayPreservesExplicitVision(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-gateway",
BaseURL: "https://gateway.example/v1",
Model: "deepseek-v4-pro",
Extra: map[string]any{
"reasoning_protocol": "deepseek",
"vision": true,
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if !c.deepseek || !c.vision {
t.Fatalf("deepseek=%v vision=%v, want both enabled", c.deepseek, c.vision)
}
req := c.buildRequest(provider.Request{Messages: []provider.Message{{
Role: provider.RoleUser, Content: "describe",
Images: []string{"data:image/png;base64,AAAA"},
}}})
parts, ok := req.Messages[0].Content.([]chatContentPart)
if !ok || len(parts) != 2 || parts[1].ImageURL == nil {
t.Fatalf("custom gateway content = %#v, want [text, image_url]", req.Messages[0].Content)
}
}
func TestImageURLDetailFromConfig(t *testing.T) {
c := &client{model: "gpt-4o", vision: true, visionDetail: "low"}
req := c.buildRequest(provider.Request{
+9 -1
View File
@@ -79,12 +79,20 @@ func New(cfg provider.Config) (provider.Provider, error) {
headers, _ := cfg.Extra["headers"].(map[string]string)
extraBody, _ := cfg.Extra["extra_body"].(map[string]any)
vision, _ := cfg.Extra["vision"].(bool)
explicitModelVision, _ := cfg.Extra["vision_model_explicit"].(bool)
officialDeepSeek := IsDeepSeek(cfg.BaseURL)
// DeepSeek's official chat API accepts string message content only. Keep
// this provider-boundary guard even though config capability resolution
// normally prevents image attachments from reaching this layer. A positive
// model-scoped capability can opt in without letting stale provider-wide
// vision=true settings affect current text-only models.
vision = vision && (!officialDeepSeek || explicitModelVision)
visionDetail, _ := cfg.Extra["vision_detail"].(string)
visionDetail = strings.ToLower(strings.TrimSpace(visionDetail))
if visionDetail != "low" && visionDetail != "high" {
visionDetail = "" // auto — omit the field
}
deepseek := protocol == "deepseek" || (protocol == "" && IsDeepSeek(cfg.BaseURL))
deepseek := protocol == "deepseek" || (protocol == "" && officialDeepSeek)
minimax := protocol == "" && IsMiniMax(cfg.BaseURL)
zhipu := protocol == "" && IsZhipu(cfg.BaseURL)
longcat := protocol == "" && IsLongCat(cfg.BaseURL)