返回 DeepSeek-Reasonix
independent_web_search.go
根目录 / internal / config / independent_web_search.go
1 package config
2
3 import (
4 "fmt"
5 "reasonix/internal/provider"
6 "slices"
7 "strings"
8 )
9
10 // IsOfficialDeepSeekSearchEndpoint also recognizes Chat Completions accounts:
11 // their search requests use a separate official Messages endpoint. Explicit
12 // request URL overrides are never redirected to a different endpoint.
13 func IsOfficialDeepSeekSearchEndpoint(e *ProviderEntry) bool {
14 if e == nil || e.RequestURL != "" || e.ChatURL != "" {
15 return false
16 }
17 if IsOfficialDeepSeekWebSearchEndpoint(e) {
18 return true
19 }
20 if !strings.EqualFold(e.Kind, "openai") {
21 return false
22 }
23 copy := *e
24 copy.Kind = "responses"
25 copy.BaseURL = strings.TrimSuffix(strings.TrimRight(e.BaseURL, "/"), "/v1")
26 return IsOfficialDeepSeekWebSearchEndpoint(&copy)
27 }
28
29 // EffectiveIndependentWebSearch preserves the existing tri-state switch while
30 // allowing official Chat Completions accounts to supply an auxiliary search.
31 func EffectiveIndependentWebSearch(e *ProviderEntry) bool {
32 if e == nil || (!SupportsServerWebSearch(e) && !IsOfficialDeepSeekSearchEndpoint(e)) {
33 return false
34 }
35 if e.WebSearch != nil {
36 return *e.WebSearch
37 }
38 return EffectiveWebSearch(e) || IsOfficialDeepSeekSearchEndpoint(e)
39 }
40
41 // ResolveWebSearchProvider freezes one search route for a runtime assembly.
42 // Prefer the selected chat account; otherwise use the first enabled configured
43 // search account. An explicit disable on a search-capable current account wins
44 // over fallback. Exact official routes use the source-rich Messages API.
45 // Compatible providers keep their own endpoint, credentials and model.
46 func (c *Config) resolveAutomaticWebSearchProvider(current *ProviderEntry) *ProviderEntry {
47 if c == nil {
48 return nil
49 }
50 if current != nil && current.WebSearch != nil && !*current.WebSearch &&
51 (SupportsServerWebSearch(current) || IsOfficialDeepSeekSearchEndpoint(current) || isOpenCodeGoEntry(current)) {
52 return nil
53 }
54 resolve := func(e *ProviderEntry) *ProviderEntry {
55 if !EffectiveIndependentWebSearch(e) || !e.Configured() || e.Model == "" {
56 return nil
57 }
58 copy := cloneProviderEntry(*e)
59 if IsOfficialDeepSeekSearchEndpoint(e) {
60 copy.Kind = "anthropic"
61 copy.BaseURL = "https://api.deepseek.com/anthropic"
62 copy.RequestURL = ""
63 copy.ChatURL = ""
64 copy.ReasoningProtocol = ReasoningProtocolDeepSeek
65 }
66 copy.WebSearch = boolPointer(true)
67 copy.ResponsesStateful = boolPointer(false)
68 copy.ResponsesMode = "stateless"
69 return &copy
70 }
71 if selected := resolve(current); selected != nil {
72 return selected
73 }
74 if isOpenCodeGoEntry(current) {
75 // OpenCode auto search is account-bound, including when the migrated
76 // connection has since been edited. Never fall through to another account.
77 return c.resolveOpenCodeGoAutomaticSearch(current, resolve)
78 }
79 for i := range c.Providers {
80 entry, ok := c.ResolveModel(c.Providers[i].Name)
81 if ok {
82 if selected := resolve(entry); selected != nil {
83 return selected
84 }
85 }
86 }
87 return nil
88 }
89
90 // WebSearchResolution distinguishes an invalid explicit assignment from automatic
91 // absence. Entry is a detached route frozen for one runtime.
92 type WebSearchResolution struct {
93 Entry *ProviderEntry
94 Status string
95 Reason string
96 }
97
98 // ResolveWebSearchModel resolves an exact account/model without legacy account
99 // retargeting: an explicit search assignment must never silently change accounts.
100 func (c *Config) ResolveWebSearchModel(ref string) (*ProviderEntry, error) {
101 name, model, exact := strings.Cut(strings.TrimSpace(ref), "/")
102 entry, exists := c.Provider(name)
103 if !exact || !exists || !entry.HasModel(model) {
104 var aliasErr error
105 ref, aliasErr = c.resolveOpenCodeGoAlias(ref, true)
106 if aliasErr != nil {
107 return nil, aliasErr
108 }
109 }
110 name, model, ok := strings.Cut(strings.TrimSpace(ref), "/")
111 if !ok {
112 return nil, fmt.Errorf("search model must use provider/model")
113 }
114 if c.Desktop.ProviderAccess != nil && !slices.Contains(c.Desktop.ProviderAccess, name) {
115 return nil, fmt.Errorf("search connection is not added")
116 }
117 e, found := c.Provider(name)
118 if !found || !e.HasModel(model) {
119 return nil, fmt.Errorf("search model is no longer available")
120 }
121 cp := cloneProviderEntry(*e)
122 cp.Model = model
123 cp.applyModelPrice()
124 cp.applyModelOverride()
125 if !SupportsServerWebSearch(&cp) && !IsOfficialDeepSeekSearchEndpoint(&cp) {
126 return nil, fmt.Errorf("connection does not support the native search protocol")
127 }
128 if !EffectiveIndependentWebSearch(&cp) {
129 return nil, fmt.Errorf("search is disabled on this connection")
130 }
131 if !cp.Configured() {
132 return nil, fmt.Errorf("search connection has no credentials")
133 }
134 return &cp, nil
135 }
136
137 func (c *Config) SetWebSearchModel(ref string) error {
138 ref = strings.TrimSpace(ref)
139 if ref == "" || strings.EqualFold(ref, "auto") {
140 c.Agent.WebSearchModel = "auto"
141 return nil
142 }
143 e, err := c.ResolveWebSearchModel(ref)
144 if err != nil {
145 return err
146 }
147 c.Agent.WebSearchModel = e.Name + "/" + e.Model
148 return nil
149 }
150
151 func (c *Config) ResolveWebSearch(current *ProviderEntry) WebSearchResolution {
152 if c == nil {
153 return WebSearchResolution{Status: "unavailable"}
154 }
155 if c.Environment.Offline || (len(c.Tools.Enabled) > 0 && !slices.Contains(c.Tools.Enabled, "web_search")) {
156 return WebSearchResolution{Status: "disabled"}
157 }
158 ref := strings.TrimSpace(c.Agent.WebSearchModel)
159 if ref != "" && !strings.EqualFold(ref, "auto") {
160 entry, err := c.ResolveWebSearchModel(ref)
161 if err != nil {
162 return WebSearchResolution{Status: "invalid", Reason: err.Error()}
163 }
164 route := c.resolveAutomaticWebSearchProvider(entry)
165 return WebSearchResolution{Entry: route, Status: "ready"}
166 }
167 if current != nil && current.WebSearch != nil && !*current.WebSearch && (SupportsServerWebSearch(current) || IsOfficialDeepSeekSearchEndpoint(current) || isOpenCodeGoEntry(current)) {
168 return WebSearchResolution{Status: "disabled"}
169 }
170 if isOpenCodeGoEntry(current) && c.openCodeGoJournal != nil {
171 // A saved search identity is an assignment, including in auto mode.
172 // Its disappearance must not select another account from the catalog.
173 refs := make([]string, 0, len(c.openCodeGoJournal.SearchAliases))
174 for old, alias := range c.openCodeGoJournal.SearchAliases {
175 if alias.Identity == openCodeGoIdentity(*current) {
176 refs = append(refs, old)
177 }
178 }
179 slices.Sort(refs)
180 for _, old := range refs {
181 if _, err := c.resolveHistoricalWebSearchModel(old); err != nil {
182 return WebSearchResolution{Status: "invalid", Reason: err.Error()}
183 }
184 }
185 }
186 if entry := c.resolveAutomaticWebSearchProvider(current); entry != nil {
187 return WebSearchResolution{Entry: entry, Status: "ready"}
188 }
189 return WebSearchResolution{Status: "unavailable"}
190 }
191
192 func isOpenCodeGoEntry(e *ProviderEntry) bool {
193 if e == nil {
194 return false
195 }
196 _, ok := provider.OpenCodeGoRequestRoute(e.Kind, e.BaseURL, e.RequestURL, e.ChatURL)
197 return ok
198 }
199
200 // ResolveWebSearchProvider retains the existing API for consumers that only need
201 // the route. Runtime registration uses ResolveWebSearch to surface invalid refs.
202 func (c *Config) ResolveWebSearchProvider(current *ProviderEntry) *ProviderEntry {
203 return c.ResolveWebSearch(current).Entry
204 }
205
205 lines GO