diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index ba5ce0e8..e03c2e3a 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -49,7 +49,7 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str challenge := generateChallenge() start := time.Now() - respText, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt) + respText, rawBody, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt) latency := time.Since(start) latencyMs := int(latency / time.Millisecond) res.LatencyMs = &latencyMs @@ -60,8 +60,11 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str return res } if statusCode < 200 || statusCode >= 300 { + // 错误路径:用 rawBody 而非 respText(gjson textPath 抽取在错误响应里通常为空, + // 会丢掉真正的上游错误信息,例如 `{"error":{"message":"No available accounts ..."}}`)。 res.Status = MonitorStatusError - res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", statusCode, respText))) + bodySnippet := truncateForErrorBody(rawBody) + res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", statusCode, bodySnippet))) return res } @@ -180,22 +183,27 @@ func isSupportedProvider(p string) bool { } // callProvider 通过 providerAdapters 分发到具体实现。 -// 返回值:响应中提取的文本、HTTP status、网络/序列化错误。 -func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string) (string, int, error) { +// +// 返回值: +// - extractedText: 按 textPath 抽出的成功文本,仅在 status 2xx 时有意义;非 2xx 时通常为空串 +// - rawBody: 完整响应体的字符串形式(已被 monitorResponseMaxBytes 截断),用于错误路径保留上游真实回包 +// - status: HTTP 状态码 +// - err: 网络 / 序列化错误 +func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string) (extractedText, rawBody string, status int, err error) { adapter, ok := providerAdapters[provider] if !ok { - return "", 0, fmt.Errorf("unsupported provider %q", provider) + return "", "", 0, fmt.Errorf("unsupported provider %q", provider) } body, err := adapter.buildBody(model, prompt) if err != nil { - return "", 0, fmt.Errorf("marshal body: %w", err) + return "", "", 0, fmt.Errorf("marshal body: %w", err) } full := joinURL(endpoint, adapter.buildPath(model)) - respBody, status, err := postRawJSON(ctx, full, body, adapter.buildHeaders(apiKey)) + respBytes, status, err := postRawJSON(ctx, full, body, adapter.buildHeaders(apiKey)) if err != nil { - return "", status, err + return "", "", status, err } - return gjson.GetBytes(respBody, adapter.textPath).String(), status, nil + return gjson.GetBytes(respBytes, adapter.textPath).String(), string(respBytes), status, nil } // postRawJSON 发送 POST + 已序列化好的 JSON 字节,限制响应体大小,返回响应字节、HTTP status、错误。 @@ -297,3 +305,19 @@ func truncateMessage(msg string) string { } return msg[:cutoff] + ellipsis } + +// truncateForErrorBody 把上游错误响应 body 压到 monitorErrorBodySnippetMaxBytes 以内, +// 并顺手把连续空白折成一个空格:上游 HTML 错误页常含大量缩进/换行,保留会浪费预算。 +// 被 truncateMessage 做最终总截断兜底,所以这里只负责 body 自身的精简。 +func truncateForErrorBody(body string) string { + body = strings.Join(strings.Fields(body), " ") + if len(body) <= monitorErrorBodySnippetMaxBytes { + return body + } + const ellipsis = "...(body truncated)" + cutoff := monitorErrorBodySnippetMaxBytes - len(ellipsis) + if cutoff < 0 { + cutoff = 0 + } + return body[:cutoff] + ellipsis +} diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index b61f3bdd..768a432f 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -36,6 +36,10 @@ const ( monitorMessageMaxBytes = 500 // monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。 monitorResponseMaxBytes = 64 * 1024 + // monitorErrorBodySnippetMaxBytes 非 2xx 响应时保留上游 body 片段的最大字节数。 + // 留 300 字节足够覆盖典型结构化错误(如 `{"error":{"message":"..."}}`), + // 又给 "upstream HTTP : " 前缀留出余量,避免最终被 monitorMessageMaxBytes (500) 截得太狠。 + monitorErrorBodySnippetMaxBytes = 300 // monitorChallengeMin / monitorChallengeMax challenge 操作数范围。 monitorChallengeMin = 1 monitorChallengeMax = 50 diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index b3966289..955b6487 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -187,3 +187,14 @@ export function getPlatformTagClass(platform: string): string { default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' } } + +/** 平台对应的模型文字色(仅 text-*,用于 input/text 场景)— 与 getPlatformTagClass 同色系 */ +export function getPlatformTextClass(platform: string): string { + switch (platform) { + case 'anthropic': return 'text-orange-700 dark:text-orange-400' + case 'openai': return 'text-emerald-700 dark:text-emerald-400' + case 'gemini': return 'text-blue-700 dark:text-blue-400' + case 'antigravity': return 'text-purple-700 dark:text-purple-400' + default: return '' + } +} diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue index 836ec079..4a538fcf 100644 --- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue @@ -60,13 +60,21 @@
- +
@@ -137,6 +145,7 @@ import type { ApiKey } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Toggle from '@/components/common/Toggle.vue' import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue' +import { getPlatformTextClass } from '@/components/admin/channel/types' import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue' import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue' import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'