fix(channel):修复ali百炼渠道路径拼接问题

This commit is contained in:
qian committed 2026-08-08 02:03:31 +08:00
1 parent a9f7fe5311
commit bc2f202f4f
6 files changed
+112 -12

No files matched your search

+7 -4
View File
@@ -664,10 +664,13 @@ func (s *sAdmin) GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefau
// defaultProviderURLs 供应商默认 API 地址
var defaultProviderURLs = map[int]string{
1: "https://api.openai.com",
2: "https://api.anthropic.com",
3: "https://generativelanguage.googleapis.com",
4: "https://dashscope.aliyuncs.com/compatible-mode",
1: "https://api.openai.com",
2: "https://api.anthropic.com",
3: "https://generativelanguage.googleapis.com",
// 阿里云百炼填裸域名:adaptor 会按 relay 模式自行拼 /compatible-mode/v1/...(OpenAI 兼容)、
// /apps/anthropic/v1/messages(Claude)、/api/v1/services/aigc/...(图像),
// 这里若带上 /compatible-mode 会导致路径重复而 404。
4: "https://dashscope.aliyuncs.com",
5: "https://aip.baidubce.com",
6: "https://hunyuan.tencentcloudapi.com",
7: "https://open.bigmodel.cn",
+25 -1
View File
@@ -28,7 +28,7 @@ func (a *Adaptor) Init(info *common.RelayInfo) {
// GetRequestURL 构建上游请求 URL。
// Claude 协议走 Anthropic 兼容端点,OpenAI 走 compatible-mode 端点,图像生成使用独立端点。
func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) {
baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/")
baseURL := normalizeBaseURL(info.ChannelMeta.BaseURL)
switch constant.RelayMode(info.RelayMode) {
case constant.RelayModeClaudeMessages:
@@ -161,6 +161,30 @@ func (a *Adaptor) GetChannelName() string {
var _ common.Adaptor = (*Adaptor)(nil)
// normalizeBaseURL 归一化渠道 base URL 为 DashScope 裸域名。
//
// adaptor 各端点自行拼接完整路径(compatible-mode / apps/anthropic / api/v1/services),
// 因此 base 必须是裸域名。但用户手填时有三种常见写法都很自然:
//
// https://dashscope.aliyuncs.com
// https://dashscope.aliyuncs.com/compatible-mode (旧版默认值、控制台文档)
// https://dashscope.aliyuncs.com/compatible-mode/v1 (阿里 OpenAI SDK 文档的 base_url)
//
// 后两种若直接拼接会产生 /compatible-mode/compatible-mode/v1/... 这类重复路径,
// DashScope 网关在路由层直接返回 404 且响应体为空,极难定位。
// 这里统一剥离已知后缀,让三种写法都能正常工作。
func normalizeBaseURL(raw string) string {
base := strings.TrimSuffix(strings.TrimSpace(raw), "/")
// 从长到短依次剥离,避免先匹配短后缀导致残留
for _, suffix := range []string{"/compatible-mode/v1", "/compatible-mode"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
return strings.TrimSuffix(base, "/")
}
// convertClaudeRequest 处理 Claude 入站请求的模型映射。
func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) {
if !info.ChannelMeta.IsModelMapped {
+49
View File
@@ -0,0 +1,49 @@
package ali
import "testing"
// TestNormalizeBaseURL 验证三种常见 base URL 写法都归一化为同一个裸域名,
// 防止再次出现 /compatible-mode/compatible-mode/v1/... 重复路径导致上游 404。
func TestNormalizeBaseURL(t *testing.T) {
const want = "https://dashscope.aliyuncs.com"
cases := []struct {
name string
in string
want string
}{
{"裸域名", "https://dashscope.aliyuncs.com", want},
{"裸域名带尾斜杠", "https://dashscope.aliyuncs.com/", want},
{"带 compatible-mode", "https://dashscope.aliyuncs.com/compatible-mode", want},
{"带 compatible-mode 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/", want},
{"带 compatible-mode/v1(阿里 SDK 文档写法)", "https://dashscope.aliyuncs.com/compatible-mode/v1", want},
{"带 compatible-mode/v1 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/v1/", want},
{"首尾空格", " https://dashscope.aliyuncs.com/compatible-mode/v1 ", want},
{"自定义代理域名保持不变", "https://proxy.example.com/dashscope", "https://proxy.example.com/dashscope"},
{"自定义代理域名剥离 compatible-mode", "https://proxy.example.com/compatible-mode/v1", "https://proxy.example.com"},
{"空串", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := normalizeBaseURL(c.in); got != c.want {
t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestNormalizeBaseURLIdempotent 归一化必须幂等,重复调用不改变结果。
func TestNormalizeBaseURLIdempotent(t *testing.T) {
inputs := []string{
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope.aliyuncs.com/compatible-mode",
"https://dashscope.aliyuncs.com",
}
for _, in := range inputs {
once := normalizeBaseURL(in)
if twice := normalizeBaseURL(once); twice != once {
t.Errorf("非幂等: %q -> %q -> %q", in, once, twice)
}
}
}
+4 -2
View File
@@ -308,7 +308,8 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
// 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位
return nil, constant.NewUpstreamErrorFromResponse(resp, body)
}
if info.ChannelMeta.IsModelMapped {
@@ -347,7 +348,8 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
upstreamErr.ResponseWritten = true
return &common.Usage{}, upstreamErr
}
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
// 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位
return nil, constant.NewUpstreamErrorFromResponse(resp, body)
}
return StreamHandler(ctx, resp, info, writer)
+26 -4
View File
@@ -30,21 +30,32 @@ type RelayError struct {
// RetryAfter 上游 Retry-After 头解析结果(0 = 未携带)。
// 调度重试 FSM 据此决定 429 时「原地等待重试」还是立即 failover。
RetryAfter time.Duration
// Detail 服务端诊断上下文(如上游请求 URL),仅出现在 Error() 输出即日志中。
// 客户端响应只取 Message 字段(见 WriteRelayError),故此字段不会泄露上游地址。
Detail string
}
func (e *RelayError) Error() string {
if e.Cause != nil {
return e.Message + ": " + e.Cause.Error()
msg := e.Message
if e.Detail != "" {
msg += " [" + e.Detail + "]"
}
return e.Message
if e.Cause != nil {
return msg + ": " + e.Cause.Error()
}
return msg
}
func (e *RelayError) Unwrap() error {
return e.Cause
}
// NewUpstreamError 创建上游错误
// NewUpstreamError 创建上游错误。
// message 为空时(上游返回空响应体)用状态码兜底,避免日志里出现 error=[] 这类无信息量的记录。
func NewUpstreamError(statusCode int, message string, cause error) *RelayError {
if strings.TrimSpace(message) == "" && cause == nil {
message = "upstream returned HTTP " + strconv.Itoa(statusCode) + " with empty response body"
}
return &RelayError{
StatusCode: statusCode,
Message: message,
@@ -53,6 +64,17 @@ func NewUpstreamError(statusCode int, message string, cause error) *RelayError {
}
}
// NewUpstreamErrorFromResponse 由上游响应构造错误,并把请求 URL 记入 Detail 供日志定位。
// 空响应体的 4xx 通常是 URL 路径拼错(被上游网关在路由层拒绝),此时 URL 是唯一有效线索。
func NewUpstreamErrorFromResponse(resp *http.Response, body []byte) *RelayError {
e := NewUpstreamError(resp.StatusCode, string(body), nil).
WithRetryAfter(RetryAfterFromHeader(resp.Header))
if resp.Request != nil && resp.Request.URL != nil {
e.Detail = "upstream_url=" + resp.Request.URL.String()
}
return e
}
// WithRetryAfter 附着上游 Retry-After 解析结果(链式调用,d<=0 时不生效)。
func (e *RelayError) WithRetryAfter(d time.Duration) *RelayError {
if e != nil && d > 0 {
+1 -1
View File
@@ -194,7 +194,7 @@ const providerDefaultURLs: Record<number, string> = {
1: 'https://api.openai.com',
2: 'https://api.anthropic.com',
3: 'https://generativelanguage.googleapis.com',
4: 'https://dashscope.aliyuncs.com/compatible-mode',
4: 'https://dashscope.aliyuncs.com',
6: 'https://hunyuan.tencentcloudapi.com',
7: 'https://open.bigmodel.cn/api/paas',
8: 'https://api.deepseek.com',