返回 DeepSeek-Reasonix
provider_endpoint_contract.go
根目录 / internal / config / provider_endpoint_contract.go
1 package config
2
3 import (
4 "fmt"
5 "net/url"
6 "sort"
7 "strings"
8 "sync"
9 )
10
11 // ProviderEndpointMismatch describes a high-confidence conflict between a
12 // selected protocol and an exact request URL. It is intentionally conservative:
13 // custom gateways and query-bearing routes remain user-owned.
14 type ProviderEndpointMismatch struct {
15 Protocol string
16 RequestURL string
17 Recommended string
18 }
19
20 // ProviderEndpointRepair records a high-confidence correction where an exact
21 // catalog request URL proves that the saved protocol is stale.
22 type ProviderEndpointRepair struct {
23 ProviderName string
24 RequestURL string
25 FromProtocol string
26 ToProtocol string
27 }
28
29 var providerEndpointRepairReceipts = struct {
30 sync.Mutex
31 byPath map[string][]ProviderEndpointRepair
32 }{byPath: make(map[string][]ProviderEndpointRepair)}
33
34 func (e *ProviderEndpointMismatch) Error() string {
35 if e == nil {
36 return ""
37 }
38 if e.Recommended != "" {
39 return fmt.Sprintf("provider endpoint %q does not match protocol %q; use %s", e.RequestURL, e.Protocol, e.Recommended)
40 }
41 return fmt.Sprintf("provider endpoint %q does not match protocol %q", e.RequestURL, e.Protocol)
42 }
43
44 func normalizedProviderProtocol(kind string) string {
45 kind = strings.ToLower(strings.TrimSpace(kind))
46 if kind == "dashscope-responses" {
47 return "responses"
48 }
49 return kind
50 }
51
52 func providerProtocolSuffix(kind string) string {
53 switch normalizedProviderProtocol(kind) {
54 case "anthropic":
55 return "/messages"
56 case "responses":
57 return "/responses"
58 case "openai":
59 return "/chat/completions"
60 default:
61 return ""
62 }
63 }
64
65 // ProviderRequestURL builds the complete request URL represented by one SDK
66 // base URL in the protocol registry.
67 func ProviderRequestURL(kind, baseURL string) string {
68 base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
69 if base == "" {
70 return ""
71 }
72 switch normalizedProviderProtocol(kind) {
73 case "anthropic":
74 if strings.HasSuffix(base, "/v1") {
75 return base + "/messages"
76 }
77 return base + "/v1/messages"
78 case "responses":
79 return base + "/responses"
80 case "openai":
81 return base + "/chat/completions"
82 default:
83 return base
84 }
85 }
86
87 // ProviderEffectiveRequestURL resolves current and legacy endpoint fields with
88 // the same precedence used by the runtime adapters.
89 func ProviderEffectiveRequestURL(e *ProviderEntry) string {
90 if e == nil {
91 return ""
92 }
93 if requestURL := strings.TrimSpace(e.RequestURL); requestURL != "" {
94 return requestURL
95 }
96 if normalizedProviderProtocol(e.Kind) == "openai" {
97 if chatURL := strings.TrimRight(strings.TrimSpace(e.ChatURL), "/"); chatURL != "" {
98 return chatURL
99 }
100 }
101 return ProviderRequestURL(e.Kind, e.BaseURL)
102 }
103
104 // CatalogForProviderEntry resolves metadata for installed connections. Falling
105 // back to Name keeps hidden legacy presets useful without listing them for new
106 // connections.
107 func CatalogForProviderEntry(e *ProviderEntry) (string, ProviderCatalog, bool) {
108 if e == nil {
109 return "", ProviderCatalog{}, false
110 }
111 ids := []string{strings.TrimSpace(e.PresetID), strings.TrimSpace(e.Name)}
112 for _, id := range ids {
113 if id == "" {
114 continue
115 }
116 if preset, ok := CuratedProviderPreset(id); ok {
117 return preset.ID, CatalogForProviderPreset(preset), true
118 }
119 }
120 return "", ProviderCatalog{}, false
121 }
122
123 func recommendedProviderRequestURL(kind string, catalog ProviderCatalog) string {
124 route, ok := catalog.Protocols[normalizedProviderProtocol(kind)]
125 if !ok {
126 return ""
127 }
128 return ProviderRequestURL(kind, route.BaseURL)
129 }
130
131 func normalizedExactProviderRequestURL(raw string) (string, bool) {
132 u, err := url.Parse(strings.TrimSpace(raw))
133 if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
134 return "", false
135 }
136 path := strings.TrimRight(u.EscapedPath(), "/")
137 if path == "" {
138 path = "/"
139 }
140 return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + path, true
141 }
142
143 // RepairProviderEndpointContract changes a provider protocol only when its
144 // effective request URL exactly and uniquely identifies another registered
145 // route in the provider catalog. Custom gateways and query-bearing overrides
146 // remain user-owned and continue through the validation path unchanged.
147 func RepairProviderEndpointContract(entry *ProviderEntry) (*ProviderEndpointRepair, bool) {
148 if entry == nil {
149 return nil, false
150 }
151 fromProtocol := normalizedProviderProtocol(entry.Kind)
152 requestURL := ProviderEffectiveRequestURL(entry)
153 current, ok := normalizedExactProviderRequestURL(requestURL)
154 if !ok {
155 return nil, false
156 }
157 _, catalog, ok := CatalogForProviderEntry(entry)
158 if !ok {
159 return nil, false
160 }
161
162 // Sort for deterministic behavior even though a repair is accepted only
163 // when one normalized protocol matches.
164 kinds := make([]string, 0, len(catalog.Protocols))
165 for kind := range catalog.Protocols {
166 kinds = append(kinds, kind)
167 }
168 sort.Strings(kinds)
169 matches := make(map[string]ProviderProtocolEndpoint)
170 for _, kind := range kinds {
171 route := catalog.Protocols[kind]
172 candidate, exact := normalizedExactProviderRequestURL(ProviderRequestURL(kind, route.BaseURL))
173 if exact && candidate == current {
174 matches[normalizedProviderProtocol(kind)] = route
175 }
176 }
177 if len(matches) != 1 {
178 return nil, false
179 }
180 var toProtocol string
181 var route ProviderProtocolEndpoint
182 for toProtocol, route = range matches {
183 }
184 if toProtocol == "" || toProtocol == fromProtocol {
185 return nil, false
186 }
187
188 repair := &ProviderEndpointRepair{
189 ProviderName: entry.DisplayName,
190 RequestURL: requestURL,
191 FromProtocol: fromProtocol,
192 ToProtocol: toProtocol,
193 }
194 if strings.TrimSpace(repair.ProviderName) == "" {
195 repair.ProviderName = entry.Name
196 }
197 entry.Kind = toProtocol
198 entry.BaseURL = route.BaseURL
199 entry.RequestURL = ""
200 entry.ChatURL = ""
201 entry.AuthHeader = route.AuthHeader
202 entry.ResponsesStateful = nil
203 if toProtocol == "responses" {
204 entry.ResponsesMode = route.ResponsesMode
205 } else {
206 entry.ResponsesMode = ""
207 }
208 return repair, true
209 }
210
211 func repairProviderEndpointContracts(c *Config) []ProviderEndpointRepair {
212 if c == nil {
213 return nil
214 }
215 var repairs []ProviderEndpointRepair
216 for i := range c.Providers {
217 if repair, changed := RepairProviderEndpointContract(&c.Providers[i]); changed {
218 repairs = append(repairs, *repair)
219 }
220 }
221 return repairs
222 }
223
224 func recordProviderEndpointRepairs(path string, repairs []ProviderEndpointRepair) {
225 path = strings.TrimSpace(path)
226 if path == "" || len(repairs) == 0 {
227 return
228 }
229 providerEndpointRepairReceipts.Lock()
230 providerEndpointRepairReceipts.byPath[path] = append(providerEndpointRepairReceipts.byPath[path], repairs...)
231 providerEndpointRepairReceipts.Unlock()
232 }
233
234 // TakeProviderEndpointRepairReceipts returns startup repairs that occurred
235 // before the active runtime had an event sink, then clears the process-local
236 // receipt so concurrent tab builds do not repeat the notice.
237 func TakeProviderEndpointRepairReceipts(path string) []ProviderEndpointRepair {
238 path = strings.TrimSpace(path)
239 if path == "" {
240 return nil
241 }
242 providerEndpointRepairReceipts.Lock()
243 defer providerEndpointRepairReceipts.Unlock()
244 repairs := append([]ProviderEndpointRepair(nil), providerEndpointRepairReceipts.byPath[path]...)
245 delete(providerEndpointRepairReceipts.byPath, path)
246 return repairs
247 }
248
249 // ProviderEndpointMismatchForEntry validates only explicit, recognizable
250 // conflicts. Unknown paths, hosts, query strings and fragments are preserved.
251 func ProviderEndpointMismatchForEntry(e *ProviderEntry) *ProviderEndpointMismatch {
252 if e == nil {
253 return nil
254 }
255 kind := normalizedProviderProtocol(e.Kind)
256 expectedSuffix := providerProtocolSuffix(kind)
257 requestURL := ProviderEffectiveRequestURL(e)
258 if expectedSuffix == "" || requestURL == "" {
259 return nil
260 }
261 u, err := url.Parse(requestURL)
262 if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
263 return nil
264 }
265 path := strings.TrimRight(u.EscapedPath(), "/")
266 recommended := ""
267 _, catalog, hasCatalog := CatalogForProviderEntry(e)
268 if hasCatalog {
269 recommended = recommendedProviderRequestURL(kind, catalog)
270 if recommendedURL, parseErr := url.Parse(recommended); parseErr == nil &&
271 strings.EqualFold(recommendedURL.Scheme, u.Scheme) &&
272 strings.EqualFold(recommendedURL.Host, u.Host) &&
273 strings.TrimRight(recommendedURL.EscapedPath(), "/") == path {
274 return nil
275 }
276 }
277 for _, suffix := range []string{"/v1/messages", "/messages", "/chat/completions", "/responses"} {
278 if strings.HasSuffix(path, suffix) && !strings.HasSuffix(path, expectedSuffix) {
279 return &ProviderEndpointMismatch{Protocol: kind, RequestURL: requestURL, Recommended: recommended}
280 }
281 }
282 if !hasCatalog || !strings.HasSuffix(path, expectedSuffix) {
283 return nil
284 }
285 selectedRoute, selected := catalog.Protocols[kind]
286 if !selected {
287 return nil
288 }
289 selectedBase, err := url.Parse(selectedRoute.BaseURL)
290 if err != nil || !strings.EqualFold(selectedBase.Host, u.Host) {
291 return nil
292 }
293 for otherKind, route := range catalog.Protocols {
294 if normalizedProviderProtocol(otherKind) == kind {
295 continue
296 }
297 otherBase, parseErr := url.Parse(ProviderRequestURL(otherKind, route.BaseURL))
298 if parseErr != nil || !strings.EqualFold(otherBase.Host, u.Host) {
299 continue
300 }
301 otherSuffix := providerProtocolSuffix(otherKind)
302 foreignRequestPath := strings.TrimRight(otherBase.EscapedPath(), "/")
303 foreignRoot := strings.TrimSuffix(foreignRequestPath, otherSuffix)
304 if foreignRoot == "" || foreignRoot == "/" {
305 continue
306 }
307 if path == foreignRoot+expectedSuffix {
308 return &ProviderEndpointMismatch{Protocol: kind, RequestURL: requestURL, Recommended: recommended}
309 }
310 }
311 return nil
312 }
313
314 func ValidateProviderEndpoint(e *ProviderEntry) error {
315 if mismatch := ProviderEndpointMismatchForEntry(e); mismatch != nil {
316 return mismatch
317 }
318 return nil
319 }
320
320 lines GO