diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index d4800f5..6a4bd9b 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -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", diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 9d7bbfc..3b47fbb 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -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 { diff --git a/relay/channel/ali/adaptor_test.go b/relay/channel/ali/adaptor_test.go new file mode 100644 index 0000000..16815b5 --- /dev/null +++ b/relay/channel/ali/adaptor_test.go @@ -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) + } + } +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 97c475e..26c2cff 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -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) diff --git a/relay/constant/errors.go b/relay/constant/errors.go index c33ae93..58b1095 100644 --- a/relay/constant/errors.go +++ b/relay/constant/errors.go @@ -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 { diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index fe1c0be..22fa49b 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -194,7 +194,7 @@ const providerDefaultURLs: Record = { 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',