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