| 1 | package openai |
| 2 | |
| 3 | import ( |
| 4 | "net/url" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // matchesVendorHost reports whether baseURL points at one of the canonical |
| 12 | // hostnames (exact match, case-insensitive) or at any subdomain of apex. |
| 13 | // Returns false on any parse error or empty host. |
| 14 | // |
| 15 | // We take the apex separately from the canonical because they differ: the |
| 16 | // canonical (e.g. api.minimaxi.com) is the specific endpoint, but regional |
| 17 | // subdomains like eu.minimaxi.com or us.minimaxi.com should also match — |
| 18 | // the wire shape is the same, just hosted in a different region. The bare |
| 19 | // apex (e.g. minimaxi.com) is intentionally rejected: it would only happen |
| 20 | // if the user pointed their base_url at the apex domain, which is a |
| 21 | // misconfiguration — not a path we want to silently accept. |
| 22 | func matchesVendorHost(baseURL, apex string, canonical ...string) bool { |
| 23 | u, err := url.Parse(baseURL) |
| 24 | if err != nil { |
| 25 | return false |
| 26 | } |
| 27 | host := strings.ToLower(u.Hostname()) |
| 28 | if slices.Contains(canonical, host) { |
| 29 | return true |
| 30 | } |
| 31 | return strings.HasSuffix(host, "."+apex) |
| 32 | } |
| 33 | |
| 34 | // IsDeepSeek reports whether baseURL points at DeepSeek's API |
| 35 | // (api.deepseek.com or any *.deepseek.com subdomain). |
| 36 | func IsDeepSeek(baseURL string) bool { |
| 37 | return matchesVendorHost(baseURL, "deepseek.com", "api.deepseek.com") |
| 38 | } |
| 39 | |
| 40 | // OfficialDeepSeekVisionModel has built-in image support. Unknown models need |
| 41 | // capability metadata or an explicit declaration, not a name-based guess. |
| 42 | const OfficialDeepSeekVisionModel = provider.OfficialDeepSeekVisionModel |
| 43 | |
| 44 | // IsOfficialDeepSeekVisionModel reports whether model is the pinned official |
| 45 | // DeepSeek vision SKU. Matching is case-insensitive and trims surrounding space. |
| 46 | func IsOfficialDeepSeekVisionModel(model string) bool { |
| 47 | return strings.EqualFold(strings.TrimSpace(model), OfficialDeepSeekVisionModel) |
| 48 | } |
| 49 | |
| 50 | // The official DeepSeek model lists live in the provider package so the local |
| 51 | // model catalog can consult them without an openai import cycle. |
| 52 | func IsOfficialDeepSeekImageModel(model string) bool { |
| 53 | return provider.IsOfficialDeepSeekImageModel(model) |
| 54 | } |
| 55 | |
| 56 | func IsOfficialDeepSeekTextModel(model string) bool { |
| 57 | return provider.IsOfficialDeepSeekTextModel(model) |
| 58 | } |
| 59 | |
| 60 | // DeepSeekImageInputAllowed applies the official endpoint hard limit after a |
| 61 | // provider has resolved its configured or catalog-derived image capability. |
| 62 | func DeepSeekImageInputAllowed(officialBase bool, requestURL, model string, metadataProvided, enabled bool) bool { |
| 63 | if !officialBase && !IsDeepSeek(requestURL) { |
| 64 | return enabled |
| 65 | } |
| 66 | if IsOfficialDeepSeekTextModel(model) { |
| 67 | return false |
| 68 | } |
| 69 | return enabled || (!metadataProvided && IsOfficialDeepSeekImageModel(model)) |
| 70 | } |
| 71 | |
| 72 | // OfficialDeepSeekAllowsVision reports whether this official DeepSeek endpoint |
| 73 | // may serialize image parts for the selected model. Custom gateways never match. |
| 74 | func OfficialDeepSeekAllowsVision(baseURL, model string) bool { |
| 75 | return IsDeepSeek(baseURL) && IsOfficialDeepSeekImageModel(model) |
| 76 | } |
| 77 | |
| 78 | // IsOpenAI reports whether baseURL points at OpenAI's official API host. Keep |
| 79 | // this exact-host so a compatible gateway under another openai.com subdomain |
| 80 | // cannot accidentally receive the official max_completion_tokens wire shape. |
| 81 | func IsOpenAI(baseURL string) bool { |
| 82 | u, err := url.Parse(baseURL) |
| 83 | if err != nil { |
| 84 | return false |
| 85 | } |
| 86 | return strings.EqualFold(u.Hostname(), "api.openai.com") |
| 87 | } |
| 88 | |
| 89 | // deepSeekPrefixChatURL returns the official Beta chat endpoint that enables |
| 90 | // assistant-prefix completion. Derive it only from a URL already hosted by |
| 91 | // DeepSeek: custom gateways may opt into the DeepSeek reasoning wire shape, but |
| 92 | // must never be bypassed by an automatic request to the vendor's direct API. |
| 93 | func deepSeekPrefixChatURL(chatURL string) string { |
| 94 | if !IsDeepSeek(chatURL) { |
| 95 | return "" |
| 96 | } |
| 97 | u, err := url.Parse(strings.TrimSpace(chatURL)) |
| 98 | if err != nil || u.Scheme == "" || u.Host == "" { |
| 99 | return "" |
| 100 | } |
| 101 | u.Path = "/beta/chat/completions" |
| 102 | u.RawPath = "" |
| 103 | u.RawQuery = "" |
| 104 | u.Fragment = "" |
| 105 | return u.String() |
| 106 | } |
| 107 | |
| 108 | // IsGeminiAPI reports whether baseURL points at Google's Gemini Developer API. |
| 109 | // Keep this exact-host: other googleapis.com services do not share Gemini's |
| 110 | // model resource-name compatibility quirk. |
| 111 | func IsGeminiAPI(baseURL string) bool { |
| 112 | u, err := url.Parse(baseURL) |
| 113 | if err != nil { |
| 114 | return false |
| 115 | } |
| 116 | return strings.EqualFold(u.Hostname(), "generativelanguage.googleapis.com") |
| 117 | } |
| 118 | |
| 119 | // usesGeminiThoughtSignatures reports whether the current endpoint/model speaks |
| 120 | // Gemini's OpenAI-compatible thought-signature extension. The official endpoint |
| 121 | // is authoritative even when a custom model alias is used; compatible gateways |
| 122 | // are detected from the model ID they route (for example google/gemini-3-pro). |
| 123 | // Keeping this decision on the current client prevents a Gemini-authored history |
| 124 | // from leaking extra_content.google fields after a same-session provider switch. |
| 125 | func usesGeminiThoughtSignatures(baseURL, model string) bool { |
| 126 | if IsGeminiAPI(baseURL) { |
| 127 | return true |
| 128 | } |
| 129 | for _, segment := range strings.FieldsFunc(strings.ToLower(strings.TrimSpace(model)), func(r rune) bool { |
| 130 | return r == '/' || r == ':' |
| 131 | }) { |
| 132 | if segment == "gemini" || strings.HasPrefix(segment, "gemini-") || strings.HasPrefix(segment, "gemini_") { |
| 133 | return true |
| 134 | } |
| 135 | } |
| 136 | return false |
| 137 | } |
| 138 | |
| 139 | // normalizeModelID converts Gemini's resource-form model names returned by some |
| 140 | // /models responses into the bare IDs required by OpenAI-compatible chat calls. |
| 141 | // Other providers and already-normalized Gemini IDs pass through unchanged. |
| 142 | func normalizeModelID(baseURL, model string) string { |
| 143 | model = strings.TrimSpace(model) |
| 144 | if IsGeminiAPI(baseURL) { |
| 145 | model = strings.TrimPrefix(model, "models/") |
| 146 | } |
| 147 | return model |
| 148 | } |
| 149 | |
| 150 | // Explicit official beta alias verified against Chat Completions. Keep |
| 151 | // configuration identity exact; never case-fold arbitrary IDs or gateway calls. |
| 152 | func deepSeekChatWireModel(endpoint, model string) string { |
| 153 | u, err := url.Parse(endpoint) |
| 154 | if err == nil && u.Scheme == "https" && u.Host == "api.deepseek.com" && |
| 155 | u.User == nil && u.RawQuery == "" && u.Fragment == "" && |
| 156 | (u.Path == "/chat/completions" || u.Path == "/v1/chat/completions") && |
| 157 | model == "DeepSeek-V4.1-Flash-Expires-On-0910" { |
| 158 | return "deepseek-v4.1-flash-expires-on-0910" |
| 159 | } |
| 160 | return model |
| 161 | } |
| 162 | |
| 163 | // IsMiniMax reports whether baseURL points at MiniMax's OpenAI-compatible |
| 164 | // endpoint (api.minimaxi.com or any *.minimaxi.com subdomain). |
| 165 | // |
| 166 | // The host string is matched exactly — the spelling is `minimaxi`, not |
| 167 | // `minimax` — to avoid clashing with any future minimax-branded gateway. |
| 168 | func IsMiniMax(baseURL string) bool { |
| 169 | return matchesVendorHost(baseURL, "minimaxi.com", "api.minimaxi.com") |
| 170 | } |
| 171 | |
| 172 | // IsMiMo reports whether baseURL points at Xiaomi MiMo's OpenAI-compatible API. |
| 173 | // MiMo follows the OpenAI chat shape but authenticates with an `api-key` header |
| 174 | // instead of the usual Authorization bearer header. |
| 175 | func IsMiMo(baseURL string) bool { |
| 176 | return provider.IsMiMoEndpoint(baseURL) |
| 177 | } |
| 178 | |
| 179 | // IsZhipu reports whether baseURL points at Zhipu's OpenAI-compatible endpoint |
| 180 | // for GLM models — either the China host (open.bigmodel.cn, *.bigmodel.cn) or |
| 181 | // the international Z.ai host (api.z.ai, *.z.ai). Both speak the same wire shape, |
| 182 | // where chain-of-thought is gated by `thinking.type` (enabled|disabled) and |
| 183 | // `reasoning_effort` is silently ignored, so the client routes reasoning control |
| 184 | // to the thinking knob for either host. |
| 185 | func IsZhipu(baseURL string) bool { |
| 186 | return matchesVendorHost(baseURL, "bigmodel.cn", "open.bigmodel.cn") || |
| 187 | matchesVendorHost(baseURL, "z.ai", "api.z.ai") |
| 188 | } |
| 189 | |
| 190 | // IsTokenRhythm reports whether baseURL points at Token Rhythm's official |
| 191 | // OpenAI-compatible gateway. Keep this exact-host: model-aware protocol |
| 192 | // upgrades must not affect unrelated subdomains or similarly named relays. |
| 193 | func IsTokenRhythm(baseURL string) bool { |
| 194 | u, err := url.Parse(baseURL) |
| 195 | if err != nil { |
| 196 | return false |
| 197 | } |
| 198 | return strings.EqualFold(u.Hostname(), "tokenrhythm.studio") |
| 199 | } |
| 200 | |
| 201 | // IsLongCat reports whether baseURL points at LongCat's OpenAI-compatible API. |
| 202 | // LongCat uses the OpenAI chat shape, but gates thinking with thinking.type |
| 203 | // enabled|disabled rather than the generic reasoning_effort field. |
| 204 | func IsLongCat(baseURL string) bool { |
| 205 | return matchesVendorHost(baseURL, "longcat.chat", "api.longcat.chat") |
| 206 | } |
| 207 | |
| 208 | // IsKimiAPI reports whether baseURL is one of Moonshot's official Kimi direct |
| 209 | // API endpoints. Gate Kimi-specific wire compatibility on the exact API hosts |
| 210 | // so OpenAI-compatible relays carrying the same model ID remain untouched. |
| 211 | func IsKimiAPI(baseURL string) bool { |
| 212 | u, err := url.Parse(baseURL) |
| 213 | if err != nil { |
| 214 | return false |
| 215 | } |
| 216 | switch strings.ToLower(u.Hostname()) { |
| 217 | case "api.moonshot.cn", "api.moonshot.ai": |
| 218 | return true |
| 219 | default: |
| 220 | return false |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | // IsOllamaCloud reports whether baseURL points at Ollama Cloud's hosted |
| 225 | // OpenAI-compatible endpoint. Local Ollama servers intentionally do not match: |
| 226 | // the hosted API accepts the reasoning_effort=max extension, while localhost |
| 227 | // deployments vary by model/version. |
| 228 | func IsOllamaCloud(baseURL string) bool { |
| 229 | return matchesVendorHost(baseURL, "ollama.com", "ollama.com") |
| 230 | } |
| 231 |