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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ const (
|
||||
monitorMessageMaxBytes = 500
|
||||
// monitorResponseMaxBytes 单次模型响应最大读取字节,防止 OOM。
|
||||
monitorResponseMaxBytes = 64 * 1024
|
||||
// monitorErrorBodySnippetMaxBytes 非 2xx 响应时保留上游 body 片段的最大字节数。
|
||||
// 留 300 字节足够覆盖典型结构化错误(如 `{"error":{"message":"..."}}`),
|
||||
// 又给 "upstream HTTP <status>: " 前缀留出余量,避免最终被 monitorMessageMaxBytes (500) 截得太狠。
|
||||
monitorErrorBodySnippetMaxBytes = 300
|
||||
// monitorChallengeMin / monitorChallengeMax challenge 操作数范围。
|
||||
monitorChallengeMin = 1
|
||||
monitorChallengeMax = 50
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,21 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
|
||||
<ModelTagInput
|
||||
:models="form.extra_models"
|
||||
:platform="form.provider"
|
||||
:placeholder="t('admin.channelMonitor.form.extraModelsPlaceholder')"
|
||||
@update:models="form.extra_models = $event"
|
||||
/>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user