fix(agent): Agent 对话 - 修复无效 URL 与上游取消日志

This commit is contained in:
ddCat
2026-08-19 11:36:41 +08:00
parent c7452b701d
commit de00e211b3
4 changed files with 62 additions and 6 deletions

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 = "";

View File

@@ -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("当前模型请求地址 无效");
});
});