| 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(©) |
| 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 © |
| 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 |