| 1 | import type { ProviderCatalog } from "./providerCatalogTypes"; |
| 2 | |
| 3 | export interface ProviderEndpointConfig { |
| 4 | kind: string; |
| 5 | baseUrl: string; |
| 6 | requestUrl?: string; |
| 7 | chatUrl?: string; |
| 8 | } |
| 9 | |
| 10 | export function trimmedBaseURL(value: string): string { |
| 11 | return value.trim().replace(/\/+$/, ""); |
| 12 | } |
| 13 | |
| 14 | export function providerRequestURLFromConfig( |
| 15 | kind: string, |
| 16 | baseUrl: string, |
| 17 | requestUrl: string, |
| 18 | legacyChatUrl = "", |
| 19 | ): string { |
| 20 | const exactRequestURL = requestUrl.trim(); |
| 21 | if (exactRequestURL) return exactRequestURL; |
| 22 | if (kind.trim().toLowerCase() === "openai") { |
| 23 | const legacyOpenAIRequestURL = legacyChatUrl.trim().replace(/\/+$/, ""); |
| 24 | if (legacyOpenAIRequestURL) return legacyOpenAIRequestURL; |
| 25 | } |
| 26 | const base = trimmedBaseURL(baseUrl); |
| 27 | if (!base) return ""; |
| 28 | switch (kind.trim().toLowerCase()) { |
| 29 | case "anthropic": |
| 30 | return base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`; |
| 31 | case "responses": |
| 32 | case "dashscope-responses": |
| 33 | return `${base}/responses`; |
| 34 | default: |
| 35 | return `${base}/chat/completions`; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | export function providerBaseURLFromRequestURL(kind: string, requestUrl: string): string { |
| 40 | const exactRequestURL = requestUrl.trim(); |
| 41 | if (!exactRequestURL) return ""; |
| 42 | const normalizedKind = kind.trim().toLowerCase(); |
| 43 | const suffixes = normalizedKind === "anthropic" |
| 44 | ? ["/v1/messages"] |
| 45 | : normalizedKind === "responses" || normalizedKind === "dashscope-responses" |
| 46 | ? ["/responses"] |
| 47 | : ["/chat/completions"]; |
| 48 | try { |
| 49 | const parsed = new URL(exactRequestURL); |
| 50 | const pathname = parsed.pathname.replace(/\/+$/, ""); |
| 51 | const suffix = suffixes.find((candidate) => pathname.endsWith(candidate)); |
| 52 | parsed.pathname = suffix ? pathname.slice(0, -suffix.length) || "/" : pathname || "/"; |
| 53 | parsed.search = ""; |
| 54 | parsed.hash = ""; |
| 55 | return trimmedBaseURL(parsed.toString()); |
| 56 | } catch { |
| 57 | const suffix = suffixes.find((candidate) => exactRequestURL.endsWith(candidate)); |
| 58 | if (suffix) return trimmedBaseURL(exactRequestURL.slice(0, -suffix.length)); |
| 59 | } |
| 60 | return trimmedBaseURL(exactRequestURL); |
| 61 | } |
| 62 | |
| 63 | export function providerBaseURLForSave( |
| 64 | initial: ProviderEndpointConfig | undefined, |
| 65 | effectiveKind: string, |
| 66 | effectiveRequestUrl: string, |
| 67 | ): string { |
| 68 | const requestUrl = effectiveRequestUrl.trim(); |
| 69 | if (initial) { |
| 70 | const initialRequestUrl = providerRequestURLFromConfig( |
| 71 | initial.kind, |
| 72 | initial.baseUrl, |
| 73 | initial.requestUrl ?? "", |
| 74 | initial.chatUrl ?? "", |
| 75 | ); |
| 76 | const kindUnchanged = initial.kind.trim().toLowerCase() === effectiveKind.trim().toLowerCase(); |
| 77 | if (kindUnchanged && initialRequestUrl === requestUrl) { |
| 78 | return initial.baseUrl.trim(); |
| 79 | } |
| 80 | } |
| 81 | return providerBaseURLFromRequestURL(effectiveKind, requestUrl); |
| 82 | } |
| 83 | |
| 84 | // Only rewrite a standard API suffix on an explicit protocol selection. Custom |
| 85 | // paths, queries and fragments are user-owned and must stay byte-for-byte intact. |
| 86 | export function providerRequestURLForFormatChange(previousKind: string, nextKind: string, requestUrl: string): string { |
| 87 | if (previousKind === nextKind || !requestUrl) return requestUrl; |
| 88 | try { |
| 89 | const url = new URL(requestUrl); |
| 90 | if (url.search || url.hash) return requestUrl; |
| 91 | const previousSuffix = previousKind === "anthropic" ? "/messages" |
| 92 | : previousKind === "responses" ? "/responses" : previousKind === "openai" ? "/chat/completions" : ""; |
| 93 | if (!previousSuffix || !url.pathname.endsWith(previousSuffix)) return requestUrl; |
| 94 | const nextSuffix = nextKind === "anthropic" ? "/messages" |
| 95 | : nextKind === "responses" ? "/responses" : nextKind === "openai" ? "/chat/completions" : ""; |
| 96 | if (!nextSuffix) return requestUrl; |
| 97 | url.pathname = url.pathname.slice(0, -previousSuffix.length) + nextSuffix; |
| 98 | return url.toString(); |
| 99 | } catch { return requestUrl; } |
| 100 | } |
| 101 | |
| 102 | function normalizedProtocol(kind: string): string { |
| 103 | const normalized = kind.trim().toLowerCase(); |
| 104 | return normalized === "dashscope-responses" ? "responses" : normalized; |
| 105 | } |
| 106 | |
| 107 | function protocolSuffix(kind: string): string { |
| 108 | switch (normalizedProtocol(kind)) { |
| 109 | case "anthropic": return "/messages"; |
| 110 | case "responses": return "/responses"; |
| 111 | case "openai": return "/chat/completions"; |
| 112 | default: return ""; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | export function providerCatalogRequestURL(catalog: ProviderCatalog | undefined, kind: string): string { |
| 117 | const route = catalog?.protocols?.[normalizedProtocol(kind)]; |
| 118 | return route?.baseUrl ? providerRequestURLFromConfig(kind, route.baseUrl, "") : ""; |
| 119 | } |
| 120 | |
| 121 | function normalizedExactURL(value: string): string { |
| 122 | try { |
| 123 | const parsed = new URL(value.trim()); |
| 124 | if (parsed.search || parsed.hash || parsed.username || parsed.password) return ""; |
| 125 | parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; |
| 126 | return parsed.toString(); |
| 127 | } catch { |
| 128 | return ""; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Switch to a catalog route only when the current URL is one of that catalog's |
| 133 | // registered official routes. Custom gateways and query-bearing routes keep the |
| 134 | // conservative suffix-only behavior. |
| 135 | export function providerRequestURLForCatalogFormatChange( |
| 136 | previousKind: string, |
| 137 | nextKind: string, |
| 138 | requestUrl: string, |
| 139 | catalog?: ProviderCatalog, |
| 140 | ): string { |
| 141 | const current = normalizedExactURL(requestUrl); |
| 142 | if (current && catalog?.protocols) { |
| 143 | const matchesOfficialRoute = Object.entries(catalog.protocols).some(([kind, route]) => |
| 144 | normalizedExactURL(providerRequestURLFromConfig(kind, route.baseUrl, "")) === current, |
| 145 | ); |
| 146 | if (matchesOfficialRoute) { |
| 147 | const next = providerCatalogRequestURL(catalog, nextKind); |
| 148 | if (next) return next; |
| 149 | } |
| 150 | } |
| 151 | if (catalog?.protocols) return requestUrl; |
| 152 | return providerRequestURLForFormatChange(previousKind, nextKind, requestUrl); |
| 153 | } |
| 154 | |
| 155 | export interface ProviderEndpointMismatchDetail { |
| 156 | mismatch: boolean; |
| 157 | recommendedUrl: string; |
| 158 | } |
| 159 | |
| 160 | export function providerEndpointMismatchDetail( |
| 161 | kind: string, |
| 162 | address: string, |
| 163 | catalog?: ProviderCatalog, |
| 164 | ): ProviderEndpointMismatchDetail { |
| 165 | const recommendedUrl = providerCatalogRequestURL(catalog, kind); |
| 166 | let parsed: URL; |
| 167 | try { parsed = new URL(address); } catch { return { mismatch: false, recommendedUrl }; } |
| 168 | if (parsed.search || parsed.hash || parsed.username || parsed.password) return { mismatch: false, recommendedUrl }; |
| 169 | const path = parsed.pathname.replace(/\/+$/, ""); |
| 170 | const expected = protocolSuffix(kind); |
| 171 | if (!expected) return { mismatch: false, recommendedUrl }; |
| 172 | if (recommendedUrl && normalizedExactURL(address) === normalizedExactURL(recommendedUrl)) return { mismatch: false, recommendedUrl }; |
| 173 | const knownSuffix = ["/messages", "/chat/completions", "/responses"].find(suffix => path.endsWith(suffix)); |
| 174 | if (knownSuffix && !path.endsWith(expected)) return { mismatch: true, recommendedUrl }; |
| 175 | if (!catalog?.protocols || !path.endsWith(expected)) return { mismatch: false, recommendedUrl }; |
| 176 | |
| 177 | const selectedRoute = catalog.protocols[normalizedProtocol(kind)]; |
| 178 | if (!selectedRoute) return { mismatch: false, recommendedUrl }; |
| 179 | let selectedBase: URL; |
| 180 | try { selectedBase = new URL(selectedRoute.baseUrl); } catch { return { mismatch: false, recommendedUrl }; } |
| 181 | if (selectedBase.host.toLowerCase() !== parsed.host.toLowerCase()) return { mismatch: false, recommendedUrl }; |
| 182 | for (const [otherKind, route] of Object.entries(catalog.protocols)) { |
| 183 | if (normalizedProtocol(otherKind) === normalizedProtocol(kind)) continue; |
| 184 | let foreignBase: URL; |
| 185 | try { foreignBase = new URL(providerRequestURLFromConfig(otherKind, route.baseUrl, "")); } catch { continue; } |
| 186 | if (foreignBase.host.toLowerCase() !== parsed.host.toLowerCase()) continue; |
| 187 | const otherSuffix = protocolSuffix(otherKind); |
| 188 | const foreignRequestPath = foreignBase.pathname.replace(/\/+$/, ""); |
| 189 | const foreignRoot = foreignRequestPath.slice(0, -otherSuffix.length); |
| 190 | if (!foreignRoot || foreignRoot === "/") continue; |
| 191 | if (path === `${foreignRoot}${expected}`) { |
| 192 | return { mismatch: true, recommendedUrl }; |
| 193 | } |
| 194 | } |
| 195 | return { mismatch: false, recommendedUrl }; |
| 196 | } |
| 197 |