feat(channel-monitor): preserve upstream error body

Monitor:
- callProvider now returns both textPath-extracted text and raw body;
  runCheckForModel uses rawBody on non-2xx so history.message stops being
  "upstream HTTP 503: " with empty body (gjson textPath produces "" for
  error responses like {"error":{"message":"No available accounts..."}})
- truncateForErrorBody collapses whitespace then caps at 300 bytes
  (monitorErrorBodySnippetMaxBytes); final truncateMessage still enforces
  the 500-byte DB column cap

Frontend:
- MonitorFormDialog: primary_model input text color and ModelTagInput tags
  now both track form.provider (via new getPlatformTextClass + existing
  getPlatformTagClass with platform prop).

(cherry-picked from 1d3b0418; dropped gateway_handler logging改动,不在本 PR 范围)
This commit is contained in:
erio
2026-04-21 11:59:11 +08:00
parent ef6ec8a15a
commit b363bff1d8
4 changed files with 58 additions and 10 deletions

View File

@@ -49,7 +49,7 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
challenge := generateChallenge() challenge := generateChallenge()
start := time.Now() 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) latency := time.Since(start)
latencyMs := int(latency / time.Millisecond) latencyMs := int(latency / time.Millisecond)
res.LatencyMs = &latencyMs res.LatencyMs = &latencyMs
@@ -60,8 +60,11 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
return res return res
} }
if statusCode < 200 || statusCode >= 300 { if statusCode < 200 || statusCode >= 300 {
// 错误路径:用 rawBody 而非 respTextgjson textPath 抽取在错误响应里通常为空,
// 会丢掉真正的上游错误信息,例如 `{"error":{"message":"No available accounts ..."}}`)。
res.Status = MonitorStatusError 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 return res
} }
@@ -180,22 +183,27 @@ func isSupportedProvider(p string) bool {
} }
// callProvider 通过 providerAdapters 分发到具体实现。 // 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] adapter, ok := providerAdapters[provider]
if !ok { if !ok {
return "", 0, fmt.Errorf("unsupported provider %q", provider) return "", "", 0, fmt.Errorf("unsupported provider %q", provider)
} }
body, err := adapter.buildBody(model, prompt) body, err := adapter.buildBody(model, prompt)
if err != nil { 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)) 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 { 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、错误。 // postRawJSON 发送 POST + 已序列化好的 JSON 字节限制响应体大小返回响应字节、HTTP status、错误。
@@ -297,3 +305,19 @@ func truncateMessage(msg string) string {
} }
return msg[:cutoff] + ellipsis 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
}

View File

@@ -36,6 +36,10 @@ const (
monitorMessageMaxBytes = 500 monitorMessageMaxBytes = 500
// monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。 // monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。
monitorResponseMaxBytes = 64 * 1024 monitorResponseMaxBytes = 64 * 1024
// monitorErrorBodySnippetMaxBytes 非 2xx 响应时保留上游 body 片段的最大字节数。
// 留 300 字节足够覆盖典型结构化错误(如 `{"error":{"message":"..."}}`
// 又给 "upstream HTTP <status>: " 前缀留出余量,避免最终被 monitorMessageMaxBytes (500) 截得太狠。
monitorErrorBodySnippetMaxBytes = 300
// monitorChallengeMin / monitorChallengeMax challenge 操作数范围。 // monitorChallengeMin / monitorChallengeMax challenge 操作数范围。
monitorChallengeMin = 1 monitorChallengeMin = 1
monitorChallengeMax = 50 monitorChallengeMax = 50

View File

@@ -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' 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 ''
}
}

View File

@@ -60,13 +60,21 @@
<div> <div>
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label> <label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
<input v-model="form.primary_model" type="text" required class="input" :placeholder="t('admin.channelMonitor.form.primaryModelPlaceholder')" /> <input
v-model="form.primary_model"
type="text"
required
class="input font-medium"
:class="getPlatformTextClass(form.provider)"
:placeholder="t('admin.channelMonitor.form.primaryModelPlaceholder')"
/>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label> <label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
<ModelTagInput <ModelTagInput
:models="form.extra_models" :models="form.extra_models"
:platform="form.provider"
:placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')" :placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')"
@update:models="form.extra_models = $event" @update:models="form.extra_models = $event"
/> />
@@ -137,6 +145,7 @@ import type { ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import ModelTagInput from '@/components/admin/channel/ModelTagInput.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 MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue' import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat' import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'