package embedding import ( "fmt" "net" "net/url" "strings" ) // 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写 // 成明确的 IPv4 回环字面量。它只改主机,不碰路径、查询串、fragment 与用户信息, // 也不做任何名字解析或在线可达性探测。 // // 位置口径:规范化发生在 URL 解析之后、真正拨号之前(见 APIEmbedder.Embed), // 因此它绝不改变 HTTP 报文里除 Host 之外的任何字节。 // loopbackIPv4 是唯一的 IPv4 回环字面量。选它而不是 0.0.0.0 的理由: // 0.0.0.0 的语义是「监听所有接口」,**不是**一个可以连接的地址(实测拿它当连接 // 目标会立即失败);而 127.0.0.1 在 Windows / Linux / macOS / Termux 上行为一致, // 且完全不需要名字解析。 const loopbackIPv4 = "127.0.0.1" // normalizeEndpointURL 返回规范化后的端点 URL。 // // 规范化范围只有三类,且都是**防呆**(把写坏的地址掰回可连接的形态), // 而不是「支持拿这些地址去连」: // - "localhost":名字解析可能先命中 IPv6 ::1,而本机 embedding 服务通常只绑 // IPv4 回环(llama.cpp / ollama 默认如此)⇒ 那是一次注定无效的拨号。代价视环境 // 而定:可能是几十毫秒的无效尝试(2026-10-07 实测本机 Go 客户端约 57ms, // curl 约 0.2s),也可能在「::1 被静默丢弃」的防火墙/路由策略下一直等到系统 // 连接超时,把一个 20s 级的调用预算整段吞掉。写死 IPv4 字面量直接绕开这个 // 不确定性; // - "0.0.0.0":见 loopbackIPv4 的说明,它不是可连接的地址; // - 空主机(如 "http:///v1/embeddings"):拼出来的 URL 指向不了任何服务。 // // 不改写的:显式 IPv6 字面量("[::1]" 之类)、真实主机名 / IPv4、非 http(s) // scheme —— 使用者怎么写就怎么来。未发生改写时**逐字返回原串**;发生改写时只替换 // 主机,path / query / fragment / userinfo 由 net/url 原样重放。 func normalizeEndpointURL(raw string) (string, error) { u, err := url.Parse(raw) if err != nil { return "", fmt.Errorf("embedding: parse api url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { // 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题, // 而不是在这里改主机把它掩盖掉。 return raw, nil } host, changed := normalizeLoopbackHost(u.Hostname()) if !changed { return raw, nil } if port := u.Port(); port != "" { u.Host = net.JoinHostPort(host, port) } else { u.Host = host } return u.String(), nil } // normalizeLoopbackHost 判定主机名是否属于「不可靠的回环别名」,是则返回 // 127.0.0.1。判定大小写不敏感("LocalHost" 同样改写)。IPv6 字面量不在表内 —— // 使用者显式写了 [::1] 就尊重它,不做任何猜测。 func normalizeLoopbackHost(host string) (string, bool) { switch { case host == "": return loopbackIPv4, true case host == "0.0.0.0": return loopbackIPv4, true case strings.EqualFold(host, "localhost"): return loopbackIPv4, true default: return host, false } }