mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
6 files changed
+299
-19
No files matched your search
+14
-13
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user