Files
Cyrene_v2/internal/runtime/embedding/endpoint.go
T
AskaEth 969857682f fix(runtime): M52 把 *url.Error 直打的 19 处换成 trace.URLCause
model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进
错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走
trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据
一字不动。

清单内(17):
- model/openai.go: Chat / ChatStream 的 NewRequest、doRequest
- model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest
- multimodal/vision/adapter.go: ask 的 NewRequest、client.Do
- multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do
- multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处
- voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因,
  地址只由 redactURL(endpoint) 出现一次)
- embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest

清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉):
- voice/tts/client.go: speak 的 NewRequest 失败
- embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败

测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒
对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
2026-10-26 19:55:05 +08:00

79 lines
3.4 KiB
Go

package embedding
import (
"fmt"
"net"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写
// 成明确的 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 {
// 不回原文:url.Parse 的 *url.Error 会把整条地址(query 里可能是 api_key)渲染进去。
return "", fmt.Errorf("embedding: parse api url: %w", trace.URLCause(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
}
}