From de00e211b372be63e171bacf68d64d1b6d58ae5b Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 19 Aug 2026 11:36:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20Agent=20=E5=AF=B9=E8=AF=9D=20-=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=95=88=20URL=20=E4=B8=8E=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=8F=96=E6=B6=88=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/provider.go | 20 +++++++++++++++---- backend/internal/service/provider_test.go | 21 ++++++++++++++++++++ web/src/services/api/custom-channel-relay.ts | 19 ++++++++++++++++-- web/test/local-channel-permission.test.ts | 8 ++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 23ab60c82..747e3f65e 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -3054,12 +3054,11 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in return } status := model.ApiCallStatusSucceeded + errorCode := "" errorText := "" if requestErr != nil || statusCode < 200 || statusCode >= 300 { status = model.ApiCallStatusFailed - if requestErr != nil { - errorText = safeProviderLogError(requestErr) - } + errorCode, errorText = providerRequestErrorDetails(requestErr) } requestKind := providerRequestKind(req.Method, req.URL.Path) if metadata.RequestKind != "" { @@ -3075,7 +3074,7 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in RequestKind: requestKind, Billable: req.Method == http.MethodPost && requestKind != "cancel", APIFormat: apiFormat, Method: req.Method, Path: req.URL.Path, Model: metadata.Model, Status: status, StatusCode: statusCode, DurationMs: time.Since(startedAt).Milliseconds(), - Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, + ErrorCode: errorCode, Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, ProviderRequestID: metadata.ProviderRequestID, RequestContentType: req.Header.Get("Content-Type"), RequestBody: requestPayloadForLog(req), ResponseBody: SanitizeAPICallPayload(responseBody, ""), } channelSlotFailure := false @@ -3102,6 +3101,19 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in } } +func providerRequestErrorDetails(err error) (string, string) { + if err == nil { + return "", "" + } + if errors.Is(err, context.Canceled) { + return "request_cancelled", "任务取消,中断上游请求" + } + if errors.Is(err, context.DeadlineExceeded) { + return "upstream_timeout", "等待上游响应超时" + } + return "", safeProviderLogError(err) +} + func safeProviderLogError(err error) string { var httpErr providerHTTPError if errors.As(err, &httpErr) { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 8f4d3993f..a8e30b920 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -21,6 +21,27 @@ import ( const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8=" const testGeminiReferenceImageDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +func TestProviderRequestErrorDetails(t *testing.T) { + tests := []struct { + name string + err error + code string + text string + }{ + {name: "cancelled", err: context.Canceled, code: "request_cancelled", text: "任务取消,中断上游请求"}, + {name: "timeout", err: context.DeadlineExceeded, code: "upstream_timeout", text: "等待上游响应超时"}, + {name: "network error", err: errors.New("dial tcp: connection refused"), text: "dial tcp: connection refused"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, text := providerRequestErrorDetails(tt.err) + if code != tt.code || text != tt.text { + t.Fatalf("providerRequestErrorDetails() = (%q, %q), want (%q, %q)", code, text, tt.code, tt.text) + } + }) + } +} + func TestWriteMediaPartSanitizesFilenameAndSetsMimeType(t *testing.T) { var body bytes.Buffer writer := multipart.NewWriter(&body) diff --git a/web/src/services/api/custom-channel-relay.ts b/web/src/services/api/custom-channel-relay.ts index 7066e58fc..040a6d051 100644 --- a/web/src/services/api/custom-channel-relay.ts +++ b/web/src/services/api/custom-channel-relay.ts @@ -17,7 +17,8 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" }; } - const normalizedUpstreamUrl = new URL(upstreamUrl).toString(); + const normalizedBaseUrl = requireHttpUrl(runtimeConfig.baseUrl, "当前模型渠道 Base URL"); + const normalizedUpstreamUrl = requireHttpUrl(upstreamUrl, "当前模型请求地址"); normalizedHeaders.delete("X-Canvas-Upstream-Headers"); normalizedHeaders.delete("x-goog-api-key"); normalizedHeaders.set("Authorization", `Bearer ${runtimeConfig.apiKey}`); @@ -25,7 +26,7 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers normalizedHeaders.set("X-Canvas-Upstream-Format", runtimeConfig.apiFormat === "gemini" ? "gemini" : "openai"); if (runtimeConfig.allowLocalChannel === true) { normalizedHeaders.set("X-Canvas-Allow-Local-Channel", "1"); - normalizedHeaders.set("X-Canvas-Upstream-Base-URL", new URL(runtimeConfig.baseUrl).toString()); + normalizedHeaders.set("X-Canvas-Upstream-Base-URL", normalizedBaseUrl); } else { normalizedHeaders.delete("X-Canvas-Allow-Local-Channel"); normalizedHeaders.delete("X-Canvas-Upstream-Base-URL"); @@ -38,6 +39,20 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers }; } +function requireHttpUrl(value: string, label: string) { + const normalized = value.trim(); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error(`${label} 无效,请填写完整地址,例如:https://api.example.com/v1`); + } + if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + throw new Error(`${label} 无效,请填写完整地址,例如:https://api.example.com/v1`); + } + return parsed.toString(); +} + function encodeChannelHeaders(headers: ChannelHeader[]) { const bytes = new TextEncoder().encode(JSON.stringify(headers)); let binary = ""; diff --git a/web/test/local-channel-permission.test.ts b/web/test/local-channel-permission.test.ts index 38a5fbedb..44a8970bb 100644 --- a/web/test/local-channel-permission.test.ts +++ b/web/test/local-channel-permission.test.ts @@ -120,4 +120,12 @@ describe("允许本机渠道前端请求合同", () => { setLocalChannelRuntime(true, "localhost"); expect(channelRequest(config, "http://127.0.0.1:8000/v1/responses").headers["x-canvas-allow-local-channel"]).toBe("1"); }); + + test("非法自定义渠道 Base URL 返回可读错误,而不是原生 URL 异常", () => { + expect(() => channelRequest({ baseUrl: "api.example.com/v1", apiKey: "key", apiFormat: "openai" }, "api.example.com/v1/images/generations")).toThrow("当前模型渠道 Base URL 无效"); + }); + + test("非法上游请求地址返回可读错误", () => { + expect(() => channelRequest({ baseUrl: "https://api.example.com/v1", apiKey: "key", apiFormat: "openai" }, "api.example.com/v1/images/generations")).toThrow("当前模型请求地址 无效"); + }); });