| 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 |