返回 DeepSeek-Reasonix
vision.go
根目录 / internal / config / vision.go
1 package config
2
3 import (
4 "net/url"
5 "slices"
6 "strings"
7
8 "reasonix/internal/provider/openai"
9 )
10
11 var mimoVisionModels = map[string]bool{
12 "mimo-v2.5": true,
13 "mimo-v2-omni": true,
14 }
15
16 // VisionCapability is the model-level image-input fact used by settings and
17 // turn preparation. Unknown is deliberately distinct from unsupported: an
18 // unknown model is kept safe by the text-only path without claiming that the
19 // provider can never accept images.
20 type VisionCapability string
21
22 const (
23 VisionCapabilityUnknown VisionCapability = "unknown"
24 VisionCapabilitySupported VisionCapability = "supported"
25 VisionCapabilityUnsupported VisionCapability = "unsupported"
26 )
27
28 // InferVisionModels returns model IDs that look like chat models with image-input
29 // support. It is intentionally conservative and meant for Settings hints; an
30 // explicit capability declaration remains the runtime source of truth.
31 func InferVisionModels(models []string) []string {
32 out := make([]string, 0, len(models))
33 seen := map[string]bool{}
34 for _, model := range models {
35 model = strings.TrimSpace(model)
36 if model == "" || seen[model] || !IsLikelyChatModel(model) || !IsLikelyVisionModel(model) {
37 continue
38 }
39 seen[model] = true
40 out = append(out, model)
41 }
42 return out
43 }
44
45 func IsLikelyVisionModel(model string) bool {
46 model = strings.TrimSpace(model)
47 if model == "" {
48 return false
49 }
50 lower := strings.ToLower(model)
51 if mimoVisionModels[lower] {
52 return true
53 }
54 tokens := strings.FieldsFunc(lower, modelTokenSeparator)
55 if slices.Contains(tokens, "audio") {
56 return false
57 }
58 if strings.HasPrefix(lower, "gpt-4o") {
59 return true
60 }
61 for _, token := range tokens {
62 switch token {
63 case "vl", "vision", "visual", "multimodal", "omni":
64 return true
65 }
66 }
67 return false
68 }
69
70 func modelTokenSeparator(r rune) bool {
71 return r == '-' || r == '_' || r == '.' || r == '/' || r == ':'
72 }
73
74 // CanConfigureVision is retained for the desktop payload contract. Capability
75 // choices are now derived from model metadata; the wire layer still refuses
76 // unsupported official DeepSeek Flash/Pro image payloads.
77 func CanConfigureVision(e *ProviderEntry) bool {
78 return e != nil
79 }
80
81 // EffectiveVision resolves whether the selected model accepts image input.
82 // Official DeepSeek Settings can mark any enabled model for image input, but
83 // only the pinned vision SKU actually receives image parts. An unconfigured
84 // catalog (VisionModels == nil) still enables that SKU so CLI/model-switch
85 // users keep image input without a Settings round-trip. Explicit provider
86 // vision still wins for custom gateways; the MiMo endpoint heuristic is
87 // deliberately limited to known MiMo endpoints so arbitrary OpenAI-compatible
88 // proxies do not get image payloads unexpectedly.
89 func EffectiveVision(e *ProviderEntry) bool {
90 return VisionCapabilityForModel(e) == VisionCapabilitySupported
91 }
92
93 // VisionCapabilityForModel resolves the selected model's image-input
94 // capability without requiring a user-maintained provider-level list. Legacy
95 // Vision/VisionModels values remain valid fallbacks while model overrides take
96 // precedence after ResolveModel applies them.
97 func VisionCapabilityForModel(e *ProviderEntry) VisionCapability {
98 if e == nil {
99 return VisionCapabilityUnknown
100 }
101 if openai.IsDeepSeek(e.BaseURL) {
102 if officialDeepSeekEffectiveVision(e) {
103 return VisionCapabilitySupported
104 }
105 return VisionCapabilityUnsupported
106 }
107 if e.visionOverride != nil {
108 if *e.visionOverride {
109 return VisionCapabilitySupported
110 }
111 return VisionCapabilityUnsupported
112 }
113 if e.Vision {
114 return VisionCapabilitySupported
115 }
116 if e.HasVisionModel(e.Model) {
117 return VisionCapabilitySupported
118 }
119 if isOfficialMimoVisionEntry(e) {
120 return VisionCapabilitySupported
121 }
122 if e.VisionModels != nil {
123 return VisionCapabilityUnsupported
124 }
125 return VisionCapabilityUnknown
126 }
127
128 // ExplicitModelVision reports whether the selected model has an explicit,
129 // positive image capability declaration that the endpoint is allowed to use.
130 // Keep this query separate from EffectiveVision so callers can distinguish a
131 // model-scoped capability from provider-wide or endpoint-inferred support.
132 func ExplicitModelVision(e *ProviderEntry) bool {
133 if e == nil {
134 return false
135 }
136 if openai.IsDeepSeek(e.BaseURL) {
137 return officialDeepSeekEffectiveVision(e)
138 }
139 enabled, explicit := explicitModelVision(e)
140 return explicit && enabled
141 }
142
143 func officialDeepSeekEffectiveVision(e *ProviderEntry) bool {
144 if e == nil || openai.IsOfficialDeepSeekTextModel(e.Model) {
145 return false
146 }
147 if enabled, explicit := explicitModelVision(e); explicit {
148 return enabled
149 }
150 if !openai.IsOfficialDeepSeekImageModel(e.Model) {
151 return false
152 }
153 if e.Vision {
154 return true
155 }
156 // An explicitly emptied list means the user turned image input off for the
157 // provider. A list curated before the V4.1 SKUs still omits them, so a
158 // non-empty list must not veto a model the vendor reports as capable.
159 if e.VisionModels != nil && len(e.VisionModels) == 0 {
160 return false
161 }
162 return true
163 }
164
165 func explicitModelVision(e *ProviderEntry) (enabled, explicit bool) {
166 if e == nil {
167 return false, false
168 }
169 if e.visionOverride != nil {
170 return *e.visionOverride, true
171 }
172 if e.HasVisionModel(e.Model) {
173 return true, true
174 }
175 return false, false
176 }
177
178 func (e *ProviderEntry) HasVisionModel(model string) bool {
179 model = strings.TrimSpace(model)
180 if model == "" {
181 return false
182 }
183 for _, candidate := range e.VisionModels {
184 if strings.TrimSpace(candidate) == model {
185 return true
186 }
187 }
188 return false
189 }
190
191 func isOfficialMimoVisionEntry(e *ProviderEntry) bool {
192 if !isOpenAIProviderKind(e) || !mimoVisionModels[strings.ToLower(strings.TrimSpace(e.Model))] {
193 return false
194 }
195 switch officialMimoHost(e.BaseURL) {
196 case "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com":
197 return true
198 default:
199 return false
200 }
201 }
202
203 func officialMimoHost(baseURL string) string {
204 u, err := url.Parse(strings.TrimSpace(baseURL))
205 if err != nil {
206 return ""
207 }
208 return strings.ToLower(u.Hostname())
209 }
210
210 lines GO