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