返回 DeepSeek-Reasonix
model_capabilities.go
根目录 / internal / config / model_capabilities.go
1 package config
2
3 import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "slices"
13 "sort"
14 "strings"
15 "sync"
16 "time"
17
18 "reasonix/internal/fileutil"
19 "reasonix/internal/provider"
20 "reasonix/internal/provider/openai"
21 )
22
23 type CapabilityState string
24
25 const (
26 CapabilitySupported CapabilityState = "supported"
27 CapabilityUnsupported CapabilityState = "unsupported"
28 CapabilityUnknown CapabilityState = "unknown"
29 )
30
31 var modelCapabilityCacheWriteMu sync.Mutex
32
33 type CapabilitySource string
34
35 const (
36 CapabilitySourceOverride CapabilitySource = "override"
37 CapabilitySourcePreset CapabilitySource = "preset"
38 CapabilitySourceLegacy CapabilitySource = "legacy"
39 CapabilitySourceAdapter CapabilitySource = "adapter"
40 CapabilitySourceCache CapabilitySource = "cache"
41 CapabilitySourceDefault CapabilitySource = "adapter_default"
42 CapabilitySourceUnknown CapabilitySource = "unknown"
43 CapabilitySourceProtocol CapabilitySource = "protocol"
44 )
45
46 type ResolvedModelCapability struct {
47 Model string
48 InputModalities []provider.ModelModality
49 State CapabilityState
50 Source CapabilitySource
51 ModelInfo provider.ModelInfo
52 AutomaticState CapabilityState
53 AutomaticSource CapabilitySource
54 ImageInputEnableAllowed bool
55 ImageInputBlockReason string
56 }
57
58 type ModelCapabilityCacheFile struct {
59 Version int `json:"version"`
60 Entries []ModelCapabilityCacheEntry `json:"entries"`
61 }
62
63 type ModelCapabilityCacheEntry struct {
64 ProviderFingerprint string `json:"providerFingerprint"`
65 ModelID string `json:"modelID"`
66 InputModalities []provider.ModelModality `json:"inputModalities"`
67 Source CapabilitySource `json:"source"`
68 FetchedAt time.Time `json:"fetchedAt"`
69 ExpiresAt time.Time `json:"expiresAt"`
70 }
71
72 const (
73 modelCapabilityCacheVersion = 2
74 modelCapabilityCacheTTL = 24 * time.Hour
75 modelCapabilityCacheMaxSize = 2 << 20
76 modelCapabilityCacheMaxItems = 4096
77 )
78
79 // ModelCapabilityResolver owns the single capability decision used by boot,
80 // controller, and settings. Dynamic entries are process-local until explicitly
81 // hydrated from the sidecar cache; user config remains the higher-priority
82 // source and is never rewritten by discovery.
83 type ModelCapabilityResolver struct {
84 mu sync.RWMutex
85 entries map[string]ModelCapabilityCacheEntry
86 path string
87 credentialsRevision string
88 }
89
90 // NewTransientModelCapabilityResolver isolates unsaved credential previews from disk caches.
91 func NewTransientModelCapabilityResolver() *ModelCapabilityResolver {
92 return &ModelCapabilityResolver{
93 entries: map[string]ModelCapabilityCacheEntry{},
94 credentialsRevision: CredentialStoreRevision(),
95 }
96 }
97
98 func NewModelCapabilityResolver() *ModelCapabilityResolver {
99 r := NewTransientModelCapabilityResolver()
100 if dir := CacheDir(); dir != "" {
101 r.path = filepath.Join(dir, "model-capabilities-v2.json")
102 r.load()
103 }
104 return r
105 }
106
107 func (r *ModelCapabilityResolver) Resolve(entry *ProviderEntry) ResolvedModelCapability {
108 return r.resolveWithCredentialRevision(entry, r.credentialRevision())
109 }
110
111 func (r *ModelCapabilityResolver) credentialRevision() string {
112 if r != nil && r.credentialsRevision != "" {
113 return r.credentialsRevision
114 }
115 return CredentialStoreRevision()
116 }
117
118 func (r *ModelCapabilityResolver) resolveWithCredentialRevision(entry *ProviderEntry, credentialsRevision string) ResolvedModelCapability {
119 resolved := r.resolveAutomatic(entry, credentialsRevision)
120 resolved.AutomaticState, resolved.AutomaticSource = resolved.State, resolved.Source
121 resolved.ImageInputEnableAllowed = entry != nil
122 if entry == nil {
123 return resolved
124 }
125 // Read the exact model's override even when a catalog caller has not gone
126 // through Config.ResolveModel. Never reuse another selected model's value.
127 override := entry.visionOverride
128 if len(entry.ModelOverrides) > 0 {
129 override = nil
130 if ov, ok := entry.modelOverrideForModel(entry.Model); ok {
131 override = ov.Vision
132 }
133 }
134 if override != nil {
135 value := capabilityFromBool(resolved.Model, *override, CapabilitySourceOverride)
136 resolved.State, resolved.Source, resolved.InputModalities = value.State, value.Source, value.InputModalities
137 }
138 requestURL := entry.RequestURL
139 if requestURL == "" && entry.Kind == "openai" {
140 requestURL = entry.ChatURL
141 }
142 if (openai.IsDeepSeek(entry.BaseURL) || openai.IsDeepSeek(requestURL)) && openai.IsOfficialDeepSeekTextModel(entry.Model) {
143 resolved.State, resolved.Source = CapabilityUnsupported, CapabilitySourceProtocol
144 resolved.InputModalities = []provider.ModelModality{provider.ModalityText}
145 resolved.AutomaticState, resolved.AutomaticSource = CapabilityUnsupported, CapabilitySourceProtocol
146 resolved.ImageInputEnableAllowed = false
147 resolved.ImageInputBlockReason = "official_deepseek_text_model"
148 }
149 resolved.ModelInfo.ID = resolved.Model
150 resolved.ModelInfo.InputModalities = append([]provider.ModelModality(nil), resolved.InputModalities...)
151 return resolved
152 }
153
154 func (r *ModelCapabilityResolver) resolveAutomatic(entry *ProviderEntry, credentialsRevision string) ResolvedModelCapability {
155 if entry == nil {
156 return ResolvedModelCapability{State: CapabilityUnknown, Source: CapabilitySourceUnknown}
157 }
158 model := strings.TrimSpace(entry.Model)
159 if model == "" {
160 return ResolvedModelCapability{State: CapabilityUnknown, Source: CapabilitySourceUnknown}
161 }
162 // Resolve catalog facts separately so a vision override or legacy declaration
163 // cannot erase context/output/protocol metadata.
164 catalogURL := entry.BaseURL
165 if entry.RequestURL != "" || entry.ChatURL != "" {
166 catalogURL = entry.RequestURL
167 if catalogURL == "" {
168 catalogURL = entry.ChatURL
169 }
170 }
171 if contract, ok := provider.LookupOpenCodeGoContract(entry.Kind, entry.BaseURL, entry.RequestURL, entry.ChatURL, model); ok {
172 switch contract.Route {
173 case provider.OpenCodeGoRouteChat:
174 catalogURL = "https://opencode.ai/zen/go/v1"
175 case provider.OpenCodeGoRouteAnthropic:
176 catalogURL = "https://opencode.ai/zen/go"
177 case provider.OpenCodeGoRouteResponses:
178 catalogURL = "https://opencode.ai/zen/go/v1"
179 }
180 }
181 facts, hasFacts := provider.PiCatalogModelInfoForProvider(entry.Name, entry.Kind, catalogURL, model)
182 if !hasFacts {
183 facts, hasFacts = provider.BuiltinModelInfo(entry.Kind, catalogURL, model)
184 }
185 if info, ok := presetModelInfo(entry, model); ok {
186 resolved := capabilityFromModalities(model, info.InputModalities, CapabilitySourcePreset)
187 resolved.ModelInfo = info
188 if hasFacts {
189 resolved.ModelInfo = facts
190 }
191 return resolved
192 }
193 if entry.Vision {
194 resolved := capabilityFromBool(model, true, CapabilitySourceLegacy)
195 resolved.ModelInfo = facts
196 return resolved
197 }
198 if entry.HasVisionModel(model) {
199 resolved := capabilityFromBool(model, true, CapabilitySourceLegacy)
200 resolved.ModelInfo = facts
201 return resolved
202 }
203 if hasFacts {
204 resolved := capabilityFromModalities(model, facts.InputModalities, CapabilitySourceAdapter)
205 resolved.ModelInfo = facts
206 return resolved
207 }
208 if r != nil {
209 key := r.entryKeyWithCredentialRevision(entry, model, credentialsRevision)
210 r.mu.RLock()
211 cached, ok := r.entries[key]
212 r.mu.RUnlock()
213 if ok && time.Now().Before(cached.ExpiresAt) {
214 return capabilityFromModalities(model, cached.InputModalities, cached.Source)
215 }
216 }
217 return capabilityFromModalities(model, nil, CapabilitySourceUnknown)
218 }
219
220 // presetModelInfo turns the repository's curated provider templates into a
221 // local model catalog. It only applies to an untouched preset identity; an
222 // explicitly edited vision list remains a user-owned legacy override.
223 func presetModelInfo(entry *ProviderEntry, model string) (provider.ModelInfo, bool) {
224 if entry == nil || entry.RequestURL != "" || entry.ChatURL != "" || strings.TrimSpace(entry.PresetID) == "" {
225 return provider.ModelInfo{}, false
226 }
227 preset, ok := CuratedProviderPreset(entry.PresetID)
228 if !ok {
229 return provider.ModelInfo{}, false
230 }
231 for _, candidate := range preset.Entries {
232 if candidate.Name != entry.Name || candidate.Kind != entry.Kind || candidate.BaseURL != entry.BaseURL || !candidate.HasModel(model) {
233 continue
234 }
235 if !stringSlicesEqual(candidate.VisionModels, entry.VisionModels) {
236 return provider.ModelInfo{}, false
237 }
238 modalities := []provider.ModelModality{provider.ModalityText}
239 // The curated templates predate the V4.1 multimodal SKUs, so the vendor
240 // authority also decides here; otherwise a matching preset would lock a
241 // model that the builtin catalog already reports as image-capable.
242 if candidate.HasVisionModel(model) ||
243 (openai.IsDeepSeek(entry.BaseURL) && provider.IsOfficialDeepSeekImageModel(model)) {
244 modalities = append(modalities, provider.ModalityImage)
245 }
246 return provider.ModelInfo{ID: model, Name: model, InputModalities: modalities}, true
247 }
248 return provider.ModelInfo{}, false
249 }
250
251 func capabilityFromBool(model string, vision bool, source CapabilitySource) ResolvedModelCapability {
252 if vision {
253 return capabilityFromModalities(model, []provider.ModelModality{provider.ModalityText, provider.ModalityImage}, source)
254 }
255 return capabilityFromModalities(model, []provider.ModelModality{provider.ModalityText}, source)
256 }
257
258 func capabilityFromModalities(model string, modalities []provider.ModelModality, source CapabilitySource) ResolvedModelCapability {
259 copyModalities := append([]provider.ModelModality(nil), modalities...)
260 state := CapabilityUnsupported
261 if slices.Contains(copyModalities, provider.ModalityImage) {
262 state = CapabilitySupported
263 }
264 if modalities == nil {
265 state = CapabilityUnknown
266 }
267 return ResolvedModelCapability{Model: model, InputModalities: copyModalities, State: state, Source: source}
268 }
269
270 // PutCatalog stores adapter results for one provider identity and persists a
271 // disposable cache. Invalid entries are ignored rather than enabling images.
272 func (r *ModelCapabilityResolver) PutCatalog(entry ProviderEntry, catalog []provider.ModelInfo) {
273 r.PutCatalogAt(entry, catalog, time.Now())
274 }
275
276 // PutCatalogAt orders successful discoveries by request start, not completion.
277 // Callers must validate their frozen provider/credential identity before commit.
278 func (r *ModelCapabilityResolver) PutCatalogAt(entry ProviderEntry, catalog []provider.ModelInfo, started time.Time) {
279 if r == nil {
280 return
281 }
282 now := time.Now()
283 credentialsRevision := r.credentialRevision()
284 providerFingerprint := r.providerFingerprintForCredentialRevision(entry, credentialsRevision)
285 r.mu.Lock()
286 for _, model := range catalog {
287 id := strings.TrimSpace(model.ID)
288 if id == "" {
289 continue
290 }
291 modalities := normalizeInputModalities(model.InputModalities)
292 key := providerFingerprint + "\x00" + id
293 if previous, ok := r.entries[key]; ok && !started.After(previous.FetchedAt) {
294 continue
295 }
296 r.entries[key] = ModelCapabilityCacheEntry{
297 ProviderFingerprint: providerFingerprint,
298 ModelID: id,
299 InputModalities: modalities,
300 Source: CapabilitySourceAdapter,
301 FetchedAt: started,
302 ExpiresAt: now.Add(modelCapabilityCacheTTL),
303 }
304 }
305 r.mu.Unlock()
306 r.persist()
307 }
308
309 func normalizeInputModalities(values []provider.ModelModality) []provider.ModelModality {
310 if values == nil {
311 return nil
312 }
313 seen := map[provider.ModelModality]bool{}
314 out := make([]provider.ModelModality, 0, len(values))
315 for _, value := range values {
316 value = provider.ModelModality(strings.ToLower(strings.TrimSpace(string(value))))
317 if value != provider.ModalityText && value != provider.ModalityImage {
318 return nil
319 }
320 if !seen[value] {
321 seen[value] = true
322 out = append(out, value)
323 }
324 }
325 if len(out) == 0 {
326 return nil
327 }
328 return out
329 }
330
331 func (r *ModelCapabilityResolver) entryKey(entry *ProviderEntry, model string) string {
332 return r.entryKeyWithCredentialRevision(entry, model, r.credentialRevision())
333 }
334
335 func (r *ModelCapabilityResolver) entryKeyWithCredentialRevision(entry *ProviderEntry, model, credentialsRevision string) string {
336 return r.providerFingerprintForCredentialRevision(*entry, credentialsRevision) + "\x00" + strings.TrimSpace(model)
337 }
338
339 func (r *ModelCapabilityResolver) providerFingerprint(entry ProviderEntry) string {
340 return r.providerFingerprintForCredentialRevision(entry, r.credentialRevision())
341 }
342
343 func (r *ModelCapabilityResolver) providerFingerprintForCredentialRevision(entry ProviderEntry, credentialsRevision string) string {
344 h := hmac.New(sha256.New, []byte("reasonix-model-capabilities-cache-v2"))
345 for _, value := range []string{
346 "reasonix-model-capabilities-v2", entry.Name, entry.Kind, entry.BaseURL, entry.ChatURL, entry.RequestURL,
347 entry.ModelsURL, entry.APIKeyEnv, fmt.Sprintf("%t", entry.AuthHeader), fmt.Sprintf("%t", entry.NoProxy),
348 credentialsRevision,
349 } {
350 _, _ = fmt.Fprintf(h, "%d:", len(value))
351 _, _ = h.Write([]byte(value))
352 }
353 type headerPair struct{ key, value string }
354 headers := make([]headerPair, 0, len(entry.Headers))
355 for key, value := range entry.Headers {
356 headers = append(headers, headerPair{key: strings.ToLower(strings.TrimSpace(key)), value: value})
357 }
358 sort.Slice(headers, func(i, j int) bool { return headers[i].key < headers[j].key })
359 for _, header := range headers {
360 key, value := header.key, header.value
361 _, _ = fmt.Fprintf(h, "%d:", len(key))
362 _, _ = h.Write([]byte(key))
363 _, _ = fmt.Fprintf(h, "%d:", len(value))
364 _, _ = h.Write([]byte(value))
365 }
366 return hex.EncodeToString(h.Sum(nil))
367 }
368
369 func (r *ModelCapabilityResolver) load() {
370 if r.path == "" {
371 return
372 }
373 file, ok := readModelCapabilityCacheFile(r.path)
374 if !ok {
375 return
376 }
377 now := time.Now()
378 for _, entry := range file.Entries {
379 if entry.ProviderFingerprint == "" || entry.ModelID == "" || !now.Before(entry.ExpiresAt) {
380 continue
381 }
382 entry.InputModalities = normalizeInputModalities(entry.InputModalities)
383 entry.Source = CapabilitySourceCache
384 r.entries[entry.ProviderFingerprint+"\x00"+entry.ModelID] = entry
385 }
386 }
387
388 func (r *ModelCapabilityResolver) persist() {
389 if r == nil || r.path == "" {
390 return
391 }
392 modelCapabilityCacheWriteMu.Lock()
393 defer modelCapabilityCacheWriteMu.Unlock()
394 r.mu.RLock()
395 entries := make([]ModelCapabilityCacheEntry, 0, len(r.entries))
396 now := time.Now()
397 for _, entry := range r.entries {
398 if now.Before(entry.ExpiresAt) {
399 entry.InputModalities = append([]provider.ModelModality(nil), entry.InputModalities...)
400 entries = append(entries, entry)
401 }
402 }
403 r.mu.RUnlock()
404 sort.Slice(entries, func(i, j int) bool { return entries[i].FetchedAt.After(entries[j].FetchedAt) })
405 if len(entries) > modelCapabilityCacheMaxItems {
406 entries = entries[:modelCapabilityCacheMaxItems]
407 }
408 file := ModelCapabilityCacheFile{Version: modelCapabilityCacheVersion, Entries: entries}
409 data, err := json.MarshalIndent(file, "", " ")
410 if err != nil {
411 return
412 }
413 dir := filepath.Dir(r.path)
414 if os.MkdirAll(dir, 0o700) != nil {
415 return
416 }
417 release, err := acquireCapabilityFileLock(r.path+".lock", 2*time.Second)
418 if err != nil {
419 return
420 }
421 defer release()
422 // Merge with the latest on-disk snapshot after taking the cross-process
423 // lock. Separate settings refreshes must not erase one another's entries.
424 if existing, ok := readModelCapabilityCacheFile(r.path); ok {
425 seen := make(map[string]int, len(entries))
426 for i, entry := range entries {
427 seen[entry.ProviderFingerprint+"\x00"+entry.ModelID] = i
428 }
429 for _, entry := range existing.Entries {
430 key := entry.ProviderFingerprint + "\x00" + entry.ModelID
431 if time.Now().Before(entry.ExpiresAt) {
432 if i, ok := seen[key]; ok {
433 if entry.FetchedAt.After(entries[i].FetchedAt) {
434 entries[i] = entry
435 }
436 } else {
437 seen[key] = len(entries)
438 entries = append(entries, entry)
439 }
440 }
441 }
442 sort.Slice(entries, func(i, j int) bool { return entries[i].FetchedAt.After(entries[j].FetchedAt) })
443 if len(entries) > modelCapabilityCacheMaxItems {
444 entries = entries[:modelCapabilityCacheMaxItems]
445 }
446 file = ModelCapabilityCacheFile{Version: modelCapabilityCacheVersion, Entries: entries}
447 data, err = json.MarshalIndent(file, "", " ")
448 if err != nil {
449 return
450 }
451 }
452 // Keep this resolver consistent with the winning on-disk observations.
453 r.mu.Lock()
454 for _, entry := range entries {
455 key := entry.ProviderFingerprint + "\x00" + entry.ModelID
456 if current, ok := r.entries[key]; !ok || entry.FetchedAt.After(current.FetchedAt) {
457 r.entries[key] = entry
458 }
459 }
460 r.mu.Unlock()
461 if len(data) <= modelCapabilityCacheMaxSize {
462 // Use the repository's strict cross-platform replacement helper so an
463 // existing cache is replaced atomically on Windows as well as Unix.
464 _ = fileutil.AtomicWriteFileStrict(r.path, data, 0o600)
465 }
466 }
467
468 func readModelCapabilityCacheFile(path string) (ModelCapabilityCacheFile, bool) {
469 fileHandle, err := os.Open(path)
470 if err != nil {
471 return ModelCapabilityCacheFile{}, false
472 }
473 defer fileHandle.Close()
474 data, err := io.ReadAll(io.LimitReader(fileHandle, modelCapabilityCacheMaxSize+1))
475 if err != nil || len(data) > modelCapabilityCacheMaxSize {
476 return ModelCapabilityCacheFile{}, false
477 }
478 var file ModelCapabilityCacheFile
479 if json.Unmarshal(data, &file) != nil || file.Version != modelCapabilityCacheVersion {
480 return ModelCapabilityCacheFile{}, false
481 }
482 if len(file.Entries) > modelCapabilityCacheMaxItems {
483 return ModelCapabilityCacheFile{}, false
484 }
485 // All read paths, including cross-process persistence merges, must apply
486 // the same validation before an entry becomes visible to a resolver.
487 for i := range file.Entries {
488 entry := &file.Entries[i]
489 entry.ModelID = strings.TrimSpace(entry.ModelID)
490 if entry.ProviderFingerprint == "" || entry.ModelID == "" {
491 return ModelCapabilityCacheFile{}, false
492 }
493 entry.InputModalities = normalizeInputModalities(entry.InputModalities)
494 }
495 return file, true
496 }
497
498 func acquireCapabilityFileLock(path string, wait time.Duration) (func(), error) {
499 deadline := time.Now().Add(wait)
500 for {
501 file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
502 if err == nil {
503 _ = file.Close()
504 return func() { _ = os.Remove(path) }, nil
505 }
506 if !os.IsExist(err) || time.Now().After(deadline) {
507 return nil, err
508 }
509 time.Sleep(10 * time.Millisecond)
510 }
511 }
512
512 lines GO